Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bebb9e4b2 | ||
|
|
ac29a0c7bf | ||
|
|
ac3a39f40d | ||
|
|
491e74b905 | ||
|
|
0aa6cbf1d9 | ||
|
|
697b82a64b | ||
|
|
f06dfffbda | ||
|
|
d9ecd04747 | ||
|
|
9c279ae393 | ||
|
|
848eed0b6f | ||
|
|
043d2463a6 | ||
|
|
005ab5b585 | ||
|
|
6c61a4e871 | ||
|
|
be0b90e68f | ||
|
|
755b40aded | ||
|
|
9c1106586a | ||
|
|
9ed569f5bc | ||
|
|
b04623ebdd | ||
|
|
a4151607c5 | ||
|
|
4cc99f3f23 | ||
|
|
8b38d6fef7 |
+8
-44
@@ -1,46 +1,10 @@
|
|||||||
# Dependencies
|
node_modules/
|
||||||
node_modules
|
dist/
|
||||||
|
.turbo/
|
||||||
# Build outputs
|
|
||||||
dist
|
|
||||||
.output
|
|
||||||
.nuxt
|
|
||||||
.nitro
|
|
||||||
.data
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# TypeScript
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
.env.development
|
# Deployment runtime data (certs, ACME state, data dumps)
|
||||||
.env.*.local
|
deploy/certbot/
|
||||||
|
deploy/data-dump.json
|
||||||
# OS
|
uploads/
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
|
|
||||||
# Coverage
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Nuxt
|
|
||||||
.output
|
|
||||||
.nuxt
|
|
||||||
.nitro
|
|
||||||
.cache
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
"vue-tsc": "^3.2.8"
|
"vue-tsc": "^3.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
BatchPriorityRequest,
|
BatchPriorityRequest,
|
||||||
GoodsFilter,
|
GoodsFilter,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
|
CreateCustomGoodRequest,
|
||||||
|
UpdateCustomGoodContentRequest,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const goodsApi = {
|
export const goodsApi = {
|
||||||
@@ -26,6 +28,14 @@ export const goodsApi = {
|
|||||||
return request.post<any, Good>('/goods', data)
|
return request.post<any, Good>('/goods', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createCustomGood: (data: CreateCustomGoodRequest) => {
|
||||||
|
return request.post<any, GoodDetail>('/goods/custom', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCustomGoodContent: (id: string, data: UpdateCustomGoodContentRequest) => {
|
||||||
|
return request.patch<any, GoodDetail>(`/goods/${id}/custom-content`, data)
|
||||||
|
},
|
||||||
|
|
||||||
// Update good
|
// Update good
|
||||||
updateGood: (id: string, data: UpdateGoodRequest) => {
|
updateGood: (id: string, data: UpdateGoodRequest) => {
|
||||||
return request.patch<any, Good>(`/goods/${id}`, data)
|
return request.patch<any, Good>(`/goods/${id}`, data)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
@@ -9,22 +9,10 @@ const request: AxiosInstance = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
// Session tokens live in HttpOnly cookies — send them along.
|
||||||
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request interceptor
|
|
||||||
request.interceptors.request.use(
|
|
||||||
(config: AxiosRequestConfig) => {
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
if (token && config.headers) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
(error: AxiosError) => {
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Response interceptor
|
// Response interceptor
|
||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
(response: AxiosResponse) => {
|
(response: AxiosResponse) => {
|
||||||
@@ -35,15 +23,36 @@ request.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
return body
|
return body
|
||||||
},
|
},
|
||||||
(error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
|
// Access token expired: try the refresh cookie once, then retry the
|
||||||
|
// original request. A second 401 (refresh failed) logs the user out.
|
||||||
|
const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
config &&
|
||||||
|
!config._retried &&
|
||||||
|
!config.url?.includes('/auth/login') &&
|
||||||
|
!config.url?.includes('/auth/refresh')
|
||||||
|
) {
|
||||||
|
config._retried = true
|
||||||
|
try {
|
||||||
|
await axios.post(
|
||||||
|
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
|
||||||
|
{},
|
||||||
|
{ withCredentials: true },
|
||||||
|
)
|
||||||
|
return request.request(config)
|
||||||
|
} catch {
|
||||||
|
// fall through to the 401 handling below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const { status, data } = error.response
|
const { status, data } = error.response
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 401:
|
case 401:
|
||||||
ElMessage.error('Unauthorized, please login')
|
ElMessage.error('Unauthorized, please login')
|
||||||
localStorage.removeItem('token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
break
|
break
|
||||||
case 403:
|
case 403:
|
||||||
|
|||||||
Vendored
+5
@@ -11,12 +11,14 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
@@ -33,15 +35,18 @@ declare module 'vue' {
|
|||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory('/admin/'),
|
||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,49 +4,41 @@ import type { LoginRequest, User } from '@/types'
|
|||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
// Token persisted to localStorage
|
// The session lives in HttpOnly cookies set by the API; nothing
|
||||||
const token = ref<string>(localStorage.getItem('token') || '')
|
// security-relevant is stored client-side. `user` is just UI state,
|
||||||
|
// restored from the server via /auth/me on app start.
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const sessionChecked = ref(false)
|
||||||
|
|
||||||
// User persisted to localStorage (parsed if available)
|
// Tokens moved to HttpOnly cookies; clean up any stale values from the
|
||||||
const user = ref<User | null>(loadUser())
|
// previous localStorage-based session.
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
|
||||||
|
|
||||||
function loadUser(): User | null {
|
|
||||||
const raw = localStorage.getItem('user')
|
|
||||||
if (!raw) return null
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as User
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setToken(newToken: string) {
|
|
||||||
token.value = newToken
|
|
||||||
if (newToken) {
|
|
||||||
localStorage.setItem('token', newToken)
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
|
|
||||||
|
// Restore the session once per app start. The router guard awaits this
|
||||||
|
// so a page refresh on a protected route does not bounce to /login.
|
||||||
|
async function ensureSessionChecked() {
|
||||||
|
if (sessionChecked.value) return
|
||||||
|
sessionChecked.value = true
|
||||||
|
try {
|
||||||
|
user.value = await authApi.getCurrentUser()
|
||||||
|
} catch {
|
||||||
|
user.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUser(newUser: User | null) {
|
function setUser(newUser: User | null) {
|
||||||
user.value = newUser
|
user.value = newUser
|
||||||
if (newUser) {
|
|
||||||
localStorage.setItem('user', JSON.stringify(newUser))
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(payload: LoginRequest) {
|
async function login(payload: LoginRequest) {
|
||||||
const res = await authApi.login(payload) as any;
|
const res = await authApi.login(payload) as any
|
||||||
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? '';
|
const userData: User | null = res.user ?? res.data?.user ?? null
|
||||||
const userData: User | null = res.user ?? res.data?.user ?? null;
|
sessionChecked.value = true
|
||||||
if (accessToken) setToken(accessToken);
|
setUser(userData)
|
||||||
if (userData) setUser(userData);
|
return res
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCurrentUser() {
|
async function fetchCurrentUser() {
|
||||||
@@ -61,14 +53,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// Ignore network errors during logout
|
// Ignore network errors during logout
|
||||||
}
|
}
|
||||||
setToken('')
|
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
|
||||||
user,
|
user,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
ensureSessionChecked,
|
||||||
login,
|
login,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ export interface Good {
|
|||||||
position?: Position
|
position?: Position
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
mergedOriginGoods?: MergedOriginGoodSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergedOriginGoodSummary {
|
||||||
|
id: string
|
||||||
|
sdsGoodId: string
|
||||||
|
goodName: string | null
|
||||||
|
goodImage: string | null
|
||||||
|
goodPrice: string | null
|
||||||
|
hasDetail: boolean
|
||||||
|
variantCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OriginGoodDetail {
|
export interface OriginGoodDetail {
|
||||||
@@ -68,6 +79,49 @@ export interface OriginGoodVariant {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomGoodVariantRequest {
|
||||||
|
sku: string
|
||||||
|
sizeId?: string | null
|
||||||
|
sizeName?: string | null
|
||||||
|
colorId?: string | null
|
||||||
|
colorName?: string | null
|
||||||
|
colorHex?: string | null
|
||||||
|
imageUrl?: string | null
|
||||||
|
price?: string | null
|
||||||
|
originalPrice?: string | null
|
||||||
|
weightG?: string | null
|
||||||
|
boxLengthCm?: string | null
|
||||||
|
boxWidthCm?: string | null
|
||||||
|
boxHeightCm?: string | null
|
||||||
|
designData?: Record<string, unknown> | null
|
||||||
|
enabled?: boolean
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomGoodDetailRequest {
|
||||||
|
productCode?: string | null
|
||||||
|
englishName?: string | null
|
||||||
|
productionCycleHours?: number | null
|
||||||
|
minWeightG?: string | null
|
||||||
|
productionProcess?: string | null
|
||||||
|
materialDescription?: string | null
|
||||||
|
blankDesignUrl?: string | null
|
||||||
|
detailsPageVideoUrl?: string | null
|
||||||
|
textureName?: string | null
|
||||||
|
reminder?: string | null
|
||||||
|
productPerformance?: string | null
|
||||||
|
applicableScenarios?: string | null
|
||||||
|
washingInstructions?: string | null
|
||||||
|
specialDescription?: string | null
|
||||||
|
designExplanation?: string | null
|
||||||
|
designArea?: string | null
|
||||||
|
pictureRequest?: string | null
|
||||||
|
sizeChart?: Record<string, unknown> | null
|
||||||
|
packageSpecs?: Record<string, unknown> | null
|
||||||
|
options?: Record<string, unknown> | null
|
||||||
|
media?: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface GoodDetail extends Good {
|
export interface GoodDetail extends Good {
|
||||||
originDetail: OriginGoodDetail | null
|
originDetail: OriginGoodDetail | null
|
||||||
variants: OriginGoodVariant[]
|
variants: OriginGoodVariant[]
|
||||||
@@ -82,6 +136,28 @@ export interface CreateGoodRequest {
|
|||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
positionId?: number
|
positionId?: number
|
||||||
goodPriority?: number
|
goodPriority?: number
|
||||||
|
mergedOriginGoodIds?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomGoodRequest {
|
||||||
|
goodName: string
|
||||||
|
goodImage?: string
|
||||||
|
goodPrice?: string | null
|
||||||
|
countryId: number
|
||||||
|
categoryId: number
|
||||||
|
tagIds?: number[]
|
||||||
|
positionId?: number
|
||||||
|
goodPriority?: number
|
||||||
|
detail?: CustomGoodDetailRequest
|
||||||
|
variants?: CustomGoodVariantRequest[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCustomGoodContentRequest {
|
||||||
|
goodName?: string
|
||||||
|
goodImage?: string | null
|
||||||
|
goodPrice?: string | null
|
||||||
|
detail?: CustomGoodDetailRequest
|
||||||
|
variants?: CustomGoodVariantRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateGoodRequest {
|
export interface UpdateGoodRequest {
|
||||||
@@ -93,6 +169,7 @@ export interface UpdateGoodRequest {
|
|||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
positionId?: number | null
|
positionId?: number | null
|
||||||
goodPriority?: number
|
goodPriority?: number
|
||||||
|
mergedOriginGoodIds?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchCreateItem {
|
export interface BatchCreateItem {
|
||||||
@@ -271,6 +348,8 @@ export interface OriginGood {
|
|||||||
goodImage: string | null
|
goodImage: string | null
|
||||||
goodPrice: string | null
|
goodPrice: string | null
|
||||||
sdsGoodId: string
|
sdsGoodId: string
|
||||||
|
source: 'SDS' | 'CUSTOM'
|
||||||
|
isCustom: boolean
|
||||||
sdsCategoryId: string | null
|
sdsCategoryId: string | null
|
||||||
delisted?: boolean
|
delisted?: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { sameOriginGroup, truncateToProcess } from './origin-name';
|
||||||
|
|
||||||
|
describe('truncateToProcess', () => {
|
||||||
|
it('keeps first 3 dash segments (drops warehouse)', () => {
|
||||||
|
expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓'))
|
||||||
|
.toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花');
|
||||||
|
});
|
||||||
|
it('is identity for 3-segment names', () => {
|
||||||
|
expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'))
|
||||||
|
.toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面');
|
||||||
|
});
|
||||||
|
it('handles malformed names without country prefix', () => {
|
||||||
|
expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'))
|
||||||
|
.toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花');
|
||||||
|
});
|
||||||
|
it('returns empty for null/empty', () => {
|
||||||
|
expect(truncateToProcess(null)).toBe('');
|
||||||
|
expect(truncateToProcess('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sameOriginGroup', () => {
|
||||||
|
it('matches same product across warehouses', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓',
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓',
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
it('does not match different SKU', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花',
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG002-单面印花',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not match different country', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)T恤-DG001-单面印花',
|
||||||
|
'德国(不包邮)T恤-DG001-单面印花',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not match empty names', () => {
|
||||||
|
expect(sameOriginGroup(null, 'x-y-z')).toBe(false);
|
||||||
|
expect(sameOriginGroup('', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 原产品名匹配规则(见 data-cleaning/README.md 命名调查):
|
||||||
|
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
|
||||||
|
* 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
|
||||||
|
* 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。
|
||||||
|
*/
|
||||||
|
const KEEP_SEGMENTS = 3;
|
||||||
|
|
||||||
|
export function truncateToProcess(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
return name.split('-').slice(0, KEEP_SEGMENTS).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameOriginGroup(
|
||||||
|
a: string | null | undefined,
|
||||||
|
b: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const ka = truncateToProcess(a);
|
||||||
|
return ka !== '' && ka === truncateToProcess(b);
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@ const dialogLoading = ref(false)
|
|||||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||||
categoryName: '',
|
categoryName: '',
|
||||||
categoryIcon: '',
|
categoryIcon: '',
|
||||||
parentCategoryId: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
@@ -146,7 +145,7 @@ onMounted(fetchTree)
|
|||||||
>
|
>
|
||||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||||
<el-table-column label="图标" width="100">
|
<el-table-column label="图标" width="100">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: any">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="row.categoryIcon"
|
v-if="row.categoryIcon"
|
||||||
:src="row.categoryIcon"
|
:src="row.categoryIcon"
|
||||||
@@ -158,17 +157,17 @@ onMounted(fetchTree)
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="父级">
|
<el-table-column label="父级">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: any">
|
||||||
{{ row.parent?.categoryName || '-' }}
|
{{ row.parent?.categoryName || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="子级数" width="100">
|
<el-table-column label="子级数" width="100">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: any">
|
||||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: any">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const filter = reactive<Required<CountryFilter>>({
|
|||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await countriesApi.getCountriesList(filter)
|
await countriesApi.getCountriesList(filter)
|
||||||
const data = await countriesApi.getCountriesList(filter) as any
|
const data = await countriesApi.getCountriesList(filter) as any
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
list.value = arr
|
list.value = arr
|
||||||
@@ -147,7 +147,7 @@ onMounted(fetchList)
|
|||||||
|
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table-column label="图标" width="80">
|
<el-table-column label="图标" width="80">
|
||||||
<template #default="{ row }: { row: Country }">
|
<template #default="{ row }: any">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="row.countryIcon"
|
v-if="row.countryIcon"
|
||||||
:src="row.countryIcon"
|
:src="row.countryIcon"
|
||||||
@@ -160,12 +160,12 @@ onMounted(fetchList)
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="countryName" label="名称" min-width="200" />
|
<el-table-column prop="countryName" label="名称" min-width="200" />
|
||||||
<el-table-column label="创建时间" width="180">
|
<el-table-column label="创建时间" width="180">
|
||||||
<template #default="{ row }: { row: Country }">
|
<template #default="{ row }: any">
|
||||||
{{ new Date(row.createdAt).toLocaleString() }}
|
{{ new Date(row.createdAt).toLocaleString() }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: Country }">
|
<template #default="{ row }: any">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { useVirtualList } from '@vueuse/core'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Plus, Edit, Delete, Search, Top, Refresh,
|
Plus, Edit, Delete, Search, Top, Refresh,
|
||||||
FolderAdd, Aim, ArrowDown,
|
FolderAdd, Aim, ArrowDown, QuestionFilled,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, Position,
|
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
|
||||||
|
MergedOriginGoodSummary,
|
||||||
OriginGoodsTreeResponse,
|
OriginGoodsTreeResponse,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { goodsApi } from '@/api/goods'
|
import { goodsApi } from '@/api/goods'
|
||||||
@@ -15,9 +16,9 @@ import { countriesApi } from '@/api/countries'
|
|||||||
import { categoriesApi } from '@/api/categories'
|
import { categoriesApi } from '@/api/categories'
|
||||||
import { tagsApi } from '@/api/tags'
|
import { tagsApi } from '@/api/tags'
|
||||||
import { tagGroupsApi } from '@/api/tag-groups'
|
import { tagGroupsApi } from '@/api/tag-groups'
|
||||||
import { positionsApi } from '@/api/positions'
|
|
||||||
import { originGoodsApi } from '@/api/origin-goods'
|
import { originGoodsApi } from '@/api/origin-goods'
|
||||||
import { syncApi } from '@/api/sync'
|
import { syncApi } from '@/api/sync'
|
||||||
|
import { sameOriginGroup } from '@/utils/origin-name'
|
||||||
|
|
||||||
const mode = ref<'category' | 'country' | 'global'>('category')
|
const mode = ref<'category' | 'country' | 'global'>('category')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -170,7 +171,9 @@ function goodToNode(g: Good): any {
|
|||||||
originGoodImage: g.originGood?.goodImage || null,
|
originGoodImage: g.originGood?.goodImage || null,
|
||||||
originGoodPrice: g.originGood?.goodPrice || null,
|
originGoodPrice: g.originGood?.goodPrice || null,
|
||||||
sdsGoodId: g.originGood?.sdsGoodId || null,
|
sdsGoodId: g.originGood?.sdsGoodId || null,
|
||||||
originDelisted: !!g.originGoodId && !activeOriginGoodIds.value.has(String(g.originGoodId)),
|
isCustom: g.originGood?.isCustom === true,
|
||||||
|
mergedCount: 1 + (g.mergedOriginGoods?.length ?? 0),
|
||||||
|
originDelisted: g.originGood?.source === 'SDS' && !activeOriginGoodIds.value.has(String(g.originGoodId)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +263,7 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
|||||||
label: og.goodName,
|
label: og.goodName,
|
||||||
isOG: true,
|
isOG: true,
|
||||||
rawId: og.id,
|
rawId: og.id,
|
||||||
|
parentId: 'rc-' + node.categoryId,
|
||||||
goodName: og.goodName,
|
goodName: og.goodName,
|
||||||
goodImage: og.goodImage,
|
goodImage: og.goodImage,
|
||||||
goodPrice: og.goodPrice,
|
goodPrice: og.goodPrice,
|
||||||
@@ -364,11 +368,48 @@ const configForm = ref({
|
|||||||
countryId: '', cascaderCategory: [] as string[], categoryId: '',
|
countryId: '', cascaderCategory: [] as string[], categoryId: '',
|
||||||
tagIds: [] as string[], positionId: '', goodImage: '',
|
tagIds: [] as string[], positionId: '', goodImage: '',
|
||||||
})
|
})
|
||||||
const configPositions = ref<Position[]>([])
|
const configSiblings = ref<any[]>([])
|
||||||
|
const configChecked = ref<string[]>([])
|
||||||
|
const configPrimaryId = ref<string>('')
|
||||||
|
|
||||||
|
const checkedSiblingNodes = computed(() =>
|
||||||
|
configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))),
|
||||||
|
)
|
||||||
|
|
||||||
|
function findOgNodeById(rawId: string | number): any {
|
||||||
|
let found: any = null
|
||||||
|
function traverse(nodes: any[]) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.isOG && String(n.rawId) === String(rawId)) { found = n; return }
|
||||||
|
if (n.children?.length) traverse(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(rightTreeData.value)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSiblings(ogNode: any): any[] {
|
||||||
|
const result: any[] = []
|
||||||
|
function traverse(nodes: any[]) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.isOG && n.parentId === ogNode.parentId && String(n.rawId) !== String(ogNode.rawId) && !n.configuredCount) result.push(n)
|
||||||
|
if (n.children?.length) traverse(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(rightTreeData.value)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function openConfigModal(og: any, dropTarget: any) {
|
function openConfigModal(og: any, dropTarget: any) {
|
||||||
configOG.value = og
|
configOG.value = og
|
||||||
configDropTarget.value = dropTarget
|
configDropTarget.value = dropTarget
|
||||||
|
const ogNode = findOgNodeById(og.rawId)
|
||||||
|
const siblings = ogNode ? collectSiblings(ogNode) : []
|
||||||
|
configSiblings.value = siblings
|
||||||
|
configPrimaryId.value = String(og.rawId)
|
||||||
|
configChecked.value = siblings
|
||||||
|
.filter((s) => sameOriginGroup(ogNode?.goodName ?? og.goodName, s.goodName))
|
||||||
|
.map((s) => String(s.rawId))
|
||||||
configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', goodImage: og.goodImage || '' }
|
configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', goodImage: og.goodImage || '' }
|
||||||
if (dropTarget) {
|
if (dropTarget) {
|
||||||
if (mode.value === 'category') {
|
if (mode.value === 'category') {
|
||||||
@@ -378,7 +419,6 @@ function openConfigModal(og: any, dropTarget: any) {
|
|||||||
configForm.value.countryId = dropTarget.id
|
configForm.value.countryId = dropTarget.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadConfigPositions()
|
|
||||||
configVisible.value = true
|
configVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,19 +432,8 @@ function openConfigFromRightTree(data: any) {
|
|||||||
}, null)
|
}, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfigPositions() {
|
function onConfigCascaderChange(val: any) {
|
||||||
const params: any = { page: 1, pageSize: 200 }
|
|
||||||
if (configForm.value.countryId) params.countryId = configForm.value.countryId
|
|
||||||
if (configForm.value.categoryId) params.categoryId = configForm.value.categoryId
|
|
||||||
try {
|
|
||||||
const res = await positionsApi.getPositionsList(params) as any
|
|
||||||
configPositions.value = Array.isArray(res) ? res : (res.items ?? [])
|
|
||||||
} catch { configPositions.value = [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
function onConfigCascaderChange(val: string[]) {
|
|
||||||
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
|
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
|
||||||
loadConfigPositions()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfigSubmit() {
|
async function handleConfigSubmit() {
|
||||||
@@ -415,7 +444,10 @@ async function handleConfigSubmit() {
|
|||||||
await goodsApi.createGood({
|
await goodsApi.createGood({
|
||||||
goodName: configOG.value.goodName,
|
goodName: configOG.value.goodName,
|
||||||
goodImage: configForm.value.goodImage || undefined,
|
goodImage: configForm.value.goodImage || undefined,
|
||||||
originGoodId: Number(configOG.value.rawId),
|
originGoodId: Number(configPrimaryId.value || configOG.value.rawId),
|
||||||
|
mergedOriginGoodIds: configChecked.value
|
||||||
|
.filter((id) => id !== configPrimaryId.value)
|
||||||
|
.map(Number),
|
||||||
countryId: Number(configForm.value.countryId),
|
countryId: Number(configForm.value.countryId),
|
||||||
categoryId: Number(configForm.value.categoryId),
|
categoryId: Number(configForm.value.categoryId),
|
||||||
tagIds: configForm.value.tagIds.map(Number),
|
tagIds: configForm.value.tagIds.map(Number),
|
||||||
@@ -430,12 +462,62 @@ async function handleConfigSubmit() {
|
|||||||
} finally { configLoading.value = false }
|
} finally { configLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Custom Good ───
|
||||||
|
const customVisible = ref(false)
|
||||||
|
const customLoading = ref(false)
|
||||||
|
const customForm = ref({
|
||||||
|
goodName: '', goodImage: '', goodPrice: '', countryId: '',
|
||||||
|
cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[],
|
||||||
|
goodPriority: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCustomCreate() {
|
||||||
|
customForm.value = {
|
||||||
|
goodName: '', goodImage: '', goodPrice: '', countryId: '',
|
||||||
|
cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0,
|
||||||
|
}
|
||||||
|
customVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCustomCascaderChange(val: any) {
|
||||||
|
const path = Array.isArray(val) ? val : []
|
||||||
|
customForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCustomCreate() {
|
||||||
|
if (!customForm.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return }
|
||||||
|
if (!customForm.value.countryId) { ElMessage.warning('请选择国家'); return }
|
||||||
|
if (!customForm.value.categoryId) { ElMessage.warning('请选择分类'); return }
|
||||||
|
customLoading.value = true
|
||||||
|
try {
|
||||||
|
const created = await goodsApi.createCustomGood({
|
||||||
|
goodName: customForm.value.goodName.trim(),
|
||||||
|
goodImage: customForm.value.goodImage || undefined,
|
||||||
|
goodPrice: customForm.value.goodPrice || null,
|
||||||
|
countryId: Number(customForm.value.countryId),
|
||||||
|
categoryId: Number(customForm.value.categoryId),
|
||||||
|
tagIds: customForm.value.tagIds.map(Number),
|
||||||
|
goodPriority: customForm.value.goodPriority,
|
||||||
|
detail: {},
|
||||||
|
})
|
||||||
|
ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU')
|
||||||
|
customVisible.value = false
|
||||||
|
await refreshLeftTree()
|
||||||
|
await openEdit(created)
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.response?.data?.message || '自定义商品创建失败')
|
||||||
|
} finally { customLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Edit Good (replaces detail — click opens edit directly) ───
|
// ─── Edit Good (replaces detail — click opens edit directly) ───
|
||||||
const editVisible = ref(false)
|
const editVisible = ref(false)
|
||||||
const editLoading = ref(false)
|
const editLoading = ref(false)
|
||||||
const editDetailLoading = ref(false)
|
const editDetailLoading = ref(false)
|
||||||
const detailSyncing = ref(false)
|
const detailSyncing = ref(false)
|
||||||
const editGood = ref<Good | GoodDetail | null>(null)
|
const editGood = ref<Good | GoodDetail | null>(null)
|
||||||
|
const editMerged = ref<MergedOriginGoodSummary[]>([])
|
||||||
|
const editMergeSearch = ref('')
|
||||||
|
const originalOriginGoodId = ref<string>('')
|
||||||
const editForm = ref({
|
const editForm = ref({
|
||||||
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
||||||
categoryId: '', tagIds: [] as string[], positionId: '',
|
categoryId: '', tagIds: [] as string[], positionId: '',
|
||||||
@@ -455,9 +537,127 @@ const editSizeRows = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
|
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
|
||||||
|
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
|
||||||
|
|
||||||
|
// 候选 = 右栏树全部 og,按搜索词过滤
|
||||||
|
const editMergeCandidates = computed(() => {
|
||||||
|
if (!editMergeSearch.value) return []
|
||||||
|
const kw = editMergeSearch.value
|
||||||
|
const result: any[] = []
|
||||||
|
function traverse(nodes: any[]) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.isOG && (n.goodName ?? '').includes(kw)) result.push(n)
|
||||||
|
if (n.children?.length) traverse(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(rightTreeData.value)
|
||||||
|
return result.filter(
|
||||||
|
(n) => String(n.rawId) !== String(editGood.value?.originGoodId)
|
||||||
|
&& !editMerged.value.some((m) => m.id === String(n.rawId)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function addEditMerge(node: any) {
|
||||||
|
editMerged.value.push({
|
||||||
|
id: String(node.rawId), sdsGoodId: node.sdsGoodId,
|
||||||
|
goodName: node.goodName, goodImage: node.goodImage,
|
||||||
|
goodPrice: node.goodPrice, hasDetail: Boolean(node.hasDetail),
|
||||||
|
variantCount: node.variantCount ?? 0,
|
||||||
|
})
|
||||||
|
editMergeSearch.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function promoteMerged(m: MergedOriginGoodSummary) {
|
||||||
|
if (!editGood.value) return
|
||||||
|
const oldPrimaryId = editGood.value.originGoodId
|
||||||
|
const oldPrimary = editGood.value.originGood
|
||||||
|
editGood.value = {
|
||||||
|
...editGood.value,
|
||||||
|
originGoodId: m.id,
|
||||||
|
originGood: oldPrimary
|
||||||
|
? { ...oldPrimary, id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName }
|
||||||
|
: { id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName } as any,
|
||||||
|
}
|
||||||
|
editMerged.value = [
|
||||||
|
{ id: oldPrimaryId, sdsGoodId: oldPrimary?.sdsGoodId ?? '', goodName: oldPrimary?.goodName ?? null,
|
||||||
|
goodImage: oldPrimary?.goodImage ?? null, goodPrice: oldPrimary?.goodPrice ?? null,
|
||||||
|
hasDetail: oldPrimary?.hasDetail ?? false, variantCount: (oldPrimary as any)?.variantCount ?? 0 },
|
||||||
|
...editMerged.value.filter((x) => x.id !== m.id),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const customContentForm = ref({
|
||||||
|
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
|
||||||
|
minWeightG: '', productionProcess: '', materialDescription: '',
|
||||||
|
blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '',
|
||||||
|
productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '',
|
||||||
|
designExplanation: '', designArea: '', pictureRequest: '',
|
||||||
|
sizeChartJson: '{\n "columns": [],\n "rows": []\n}',
|
||||||
|
packageSpecsJson: '{\n "rows": []\n}',
|
||||||
|
optionsJson: '{}',
|
||||||
|
mediaJson: '{}',
|
||||||
|
variants: [] as Array<{
|
||||||
|
sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string;
|
||||||
|
price: string; originalPrice: string; weightG: string; boxLengthCm: string;
|
||||||
|
boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean
|
||||||
|
}>,
|
||||||
|
})
|
||||||
|
|
||||||
|
function fillCustomContent(g: GoodDetail) {
|
||||||
|
const detail = g.originDetail ?? {}
|
||||||
|
customContentForm.value = {
|
||||||
|
goodPrice: g.originGood?.goodPrice ?? '',
|
||||||
|
productCode: String(detail.productCode ?? ''),
|
||||||
|
englishName: String(detail.englishName ?? ''),
|
||||||
|
productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours),
|
||||||
|
minWeightG: String(detail.minWeightG ?? ''),
|
||||||
|
productionProcess: String(detail.productionProcess ?? ''),
|
||||||
|
materialDescription: String(detail.materialDescription ?? ''),
|
||||||
|
blankDesignUrl: String(detail.blankDesignUrl ?? ''),
|
||||||
|
detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''),
|
||||||
|
textureName: String(detail.textureName ?? ''),
|
||||||
|
reminder: String(detail.reminder ?? ''),
|
||||||
|
productPerformance: String(detail.productPerformance ?? ''),
|
||||||
|
applicableScenarios: String(detail.applicableScenarios ?? ''),
|
||||||
|
washingInstructions: String(detail.washingInstructions ?? ''),
|
||||||
|
specialDescription: String(detail.specialDescription ?? ''),
|
||||||
|
designExplanation: String(detail.designExplanation ?? ''),
|
||||||
|
designArea: String(detail.designArea ?? ''),
|
||||||
|
pictureRequest: String(detail.pictureRequest ?? ''),
|
||||||
|
sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2),
|
||||||
|
packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2),
|
||||||
|
optionsJson: JSON.stringify(detail.options ?? {}, null, 2),
|
||||||
|
mediaJson: JSON.stringify(detail.media ?? {}, null, 2),
|
||||||
|
variants: g.variants.map((variant) => ({
|
||||||
|
sku: variant.sku,
|
||||||
|
sizeId: String(variant.sizeId ?? ''),
|
||||||
|
sizeName: String(variant.sizeName ?? ''),
|
||||||
|
colorId: String(variant.colorId ?? ''),
|
||||||
|
colorName: String(variant.colorName ?? ''),
|
||||||
|
colorHex: String(variant.colorHex ?? ''),
|
||||||
|
imageUrl: String(variant.imageUrl ?? ''),
|
||||||
|
price: String(variant.price ?? ''),
|
||||||
|
originalPrice: String(variant.originalPrice ?? ''),
|
||||||
|
weightG: String(variant.weightG ?? ''),
|
||||||
|
boxLengthCm: String(variant.boxLengthCm ?? ''),
|
||||||
|
boxWidthCm: String(variant.boxWidthCm ?? ''),
|
||||||
|
boxHeightCm: String(variant.boxHeightCm ?? ''),
|
||||||
|
designDataJson: JSON.stringify(variant.designData ?? {}, null, 2),
|
||||||
|
enabled: variant.enabled,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomVariant() {
|
||||||
|
customContentForm.value.variants.push({
|
||||||
|
sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '',
|
||||||
|
originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function openEdit(g: Good) {
|
async function openEdit(g: Good) {
|
||||||
editGood.value = g
|
editGood.value = g
|
||||||
|
editMerged.value = (g.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
|
||||||
|
originalOriginGoodId.value = g.originGoodId
|
||||||
editForm.value = {
|
editForm.value = {
|
||||||
id: g.id, goodName: g.goodName,
|
id: g.id, goodName: g.goodName,
|
||||||
goodImage: g.goodImage || g.originGood?.goodImage || '',
|
goodImage: g.goodImage || g.originGood?.goodImage || '',
|
||||||
@@ -470,7 +670,10 @@ async function openEdit(g: Good) {
|
|||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
editDetailLoading.value = true
|
editDetailLoading.value = true
|
||||||
try {
|
try {
|
||||||
editGood.value = await goodsApi.getGoodById(g.id)
|
const detail = await goodsApi.getGoodById(g.id)
|
||||||
|
editGood.value = detail
|
||||||
|
editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
|
||||||
|
if (detail.originGood?.isCustom) fillCustomContent(detail)
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.warning('商品详情加载失败,当前显示列表数据')
|
ElMessage.warning('商品详情加载失败,当前显示列表数据')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -495,12 +698,15 @@ async function handleSyncOriginDetail(data: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncOneDetail() {
|
async function handleSyncOneDetail() {
|
||||||
|
if (editIsCustom.value) return
|
||||||
const goodId = editGood.value?.originGood?.sdsGoodId
|
const goodId = editGood.value?.originGood?.sdsGoodId
|
||||||
if (!goodId) return
|
if (!goodId) return
|
||||||
detailSyncing.value = true
|
detailSyncing.value = true
|
||||||
try {
|
try {
|
||||||
const result = await syncApi.syncOneProductDetail(goodId)
|
const result = await syncApi.syncOneProductDetail(goodId)
|
||||||
editGood.value = await goodsApi.getGoodById(editGood.value!.id)
|
const detail = await goodsApi.getGoodById(editGood.value!.id)
|
||||||
|
editGood.value = detail
|
||||||
|
editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
|
||||||
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
||||||
await Promise.all([refreshLeftTree(), refreshRightTree()])
|
await Promise.all([refreshLeftTree(), refreshRightTree()])
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -511,16 +717,88 @@ async function handleSyncOneDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditSubmit() {
|
async function handleEditSubmit() {
|
||||||
|
let customPayload: any = null
|
||||||
|
if (editIsCustom.value) {
|
||||||
|
let sizeChart: Record<string, unknown>
|
||||||
|
let packageSpecs: Record<string, unknown>
|
||||||
|
let options: Record<string, unknown>
|
||||||
|
let media: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
sizeChart = JSON.parse(customContentForm.value.sizeChartJson)
|
||||||
|
packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson)
|
||||||
|
options = JSON.parse(customContentForm.value.optionsJson)
|
||||||
|
media = JSON.parse(customContentForm.value.mediaJson)
|
||||||
|
for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) {
|
||||||
|
ElMessage.error('SKU 不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
customPayload = {
|
||||||
|
goodName: editForm.value.goodName,
|
||||||
|
goodImage: editForm.value.goodImage || null,
|
||||||
|
goodPrice: customContentForm.value.goodPrice || null,
|
||||||
|
detail: {
|
||||||
|
productCode: customContentForm.value.productCode || null,
|
||||||
|
englishName: customContentForm.value.englishName || null,
|
||||||
|
productionCycleHours: customContentForm.value.productionCycleHours ?? null,
|
||||||
|
minWeightG: customContentForm.value.minWeightG || null,
|
||||||
|
productionProcess: customContentForm.value.productionProcess || null,
|
||||||
|
materialDescription: customContentForm.value.materialDescription || null,
|
||||||
|
blankDesignUrl: customContentForm.value.blankDesignUrl || null,
|
||||||
|
detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null,
|
||||||
|
textureName: customContentForm.value.textureName || null,
|
||||||
|
reminder: customContentForm.value.reminder || null,
|
||||||
|
productPerformance: customContentForm.value.productPerformance || null,
|
||||||
|
applicableScenarios: customContentForm.value.applicableScenarios || null,
|
||||||
|
washingInstructions: customContentForm.value.washingInstructions || null,
|
||||||
|
specialDescription: customContentForm.value.specialDescription || null,
|
||||||
|
designExplanation: customContentForm.value.designExplanation || null,
|
||||||
|
designArea: customContentForm.value.designArea || null,
|
||||||
|
pictureRequest: customContentForm.value.pictureRequest || null,
|
||||||
|
sizeChart,
|
||||||
|
packageSpecs,
|
||||||
|
options,
|
||||||
|
media,
|
||||||
|
},
|
||||||
|
variants: customContentForm.value.variants.map((variant: any, index: number) => ({
|
||||||
|
sku: variant.sku.trim(),
|
||||||
|
sizeId: variant.sizeId || null,
|
||||||
|
sizeName: variant.sizeName || null,
|
||||||
|
colorId: variant.colorId || null,
|
||||||
|
colorName: variant.colorName || null,
|
||||||
|
colorHex: variant.colorHex || null,
|
||||||
|
imageUrl: variant.imageUrl || null,
|
||||||
|
price: variant.price || null,
|
||||||
|
originalPrice: variant.originalPrice || null,
|
||||||
|
weightG: variant.weightG || null,
|
||||||
|
boxLengthCm: variant.boxLengthCm || null,
|
||||||
|
boxWidthCm: variant.boxWidthCm || null,
|
||||||
|
boxHeightCm: variant.boxHeightCm || null,
|
||||||
|
designData: JSON.parse(variant.designDataJson),
|
||||||
|
enabled: variant.enabled,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
editLoading.value = true
|
editLoading.value = true
|
||||||
try {
|
try {
|
||||||
await goodsApi.updateGood(editForm.value.id, {
|
await goodsApi.updateGood(editForm.value.id, {
|
||||||
goodName: editForm.value.goodName,
|
goodName: editForm.value.goodName,
|
||||||
goodImage: editForm.value.goodImage || null,
|
goodImage: editForm.value.goodImage || null,
|
||||||
|
originGoodId: editGood.value?.originGoodId !== originalOriginGoodId.value && !editIsCustom.value
|
||||||
|
? Number(editGood.value!.originGoodId)
|
||||||
|
: undefined,
|
||||||
|
mergedOriginGoodIds: editIsCustom.value ? undefined : editMerged.value.map((m) => Number(m.id)),
|
||||||
countryId: Number(editForm.value.countryId),
|
countryId: Number(editForm.value.countryId),
|
||||||
categoryId: Number(editForm.value.categoryId),
|
categoryId: Number(editForm.value.categoryId),
|
||||||
tagIds: editForm.value.tagIds.map(Number),
|
tagIds: editForm.value.tagIds.map(Number),
|
||||||
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
|
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
|
||||||
} as any)
|
} as any)
|
||||||
|
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
|
||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
editVisible.value = false
|
editVisible.value = false
|
||||||
refreshLeftTree()
|
refreshLeftTree()
|
||||||
@@ -530,8 +808,9 @@ async function handleEditSubmit() {
|
|||||||
} finally { editLoading.value = false }
|
} finally { editLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEditCascaderChange(val: string[]) {
|
function onEditCascaderChange(val: any) {
|
||||||
editForm.value.categoryId = val.length ? val[val.length - 1] : ''
|
const path = Array.isArray(val) ? val : []
|
||||||
|
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteGood(g: Good) {
|
async function handleDeleteGood(g: Good) {
|
||||||
@@ -903,13 +1182,13 @@ const tagTreeData = computed<TreeNode[]>(() => {
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const ungrouped = allTags.value
|
const ungrouped: TreeNode[] = allTags.value
|
||||||
.filter((t) => !t.tagGroupId)
|
.filter((t) => !t.tagGroupId)
|
||||||
.sort((a, b) => a.tagName.localeCompare(b.tagName))
|
.sort((a, b) => a.tagName.localeCompare(b.tagName))
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
id: `t-${t.id}`,
|
id: `t-${t.id}`,
|
||||||
rawId: t.id,
|
rawId: t.id,
|
||||||
type: 'tag',
|
type: 'tag' as const,
|
||||||
label: t.tagName,
|
label: t.tagName,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -993,7 +1272,7 @@ async function quickCreateCountry(targetForm: () => void) {
|
|||||||
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
|
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
|
||||||
})
|
})
|
||||||
if (!value.trim()) return
|
if (!value.trim()) return
|
||||||
const res = await countriesApi.createCountry({ countryName: value.trim() } as any) as any
|
await countriesApi.createCountry({ countryName: value.trim() } as any)
|
||||||
await reloadCountries()
|
await reloadCountries()
|
||||||
targetForm()
|
targetForm()
|
||||||
ElMessage.success('已创建并选中')
|
ElMessage.success('已创建并选中')
|
||||||
@@ -1006,7 +1285,7 @@ async function quickCreateTag(targetForm: () => void) {
|
|||||||
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '标签名称',
|
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '标签名称',
|
||||||
})
|
})
|
||||||
if (!value.trim()) return
|
if (!value.trim()) return
|
||||||
const res = await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } as any) as any
|
await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } as any)
|
||||||
await reloadTags()
|
await reloadTags()
|
||||||
targetForm()
|
targetForm()
|
||||||
ElMessage.success('已创建并选中')
|
ElMessage.success('已创建并选中')
|
||||||
@@ -1264,6 +1543,7 @@ onMounted(() => loadAll())
|
|||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button>
|
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button>
|
||||||
|
<el-button size="small" type="success" :icon="Plus" @click="openCustomCreate">新增自定义商品</el-button>
|
||||||
|
|
||||||
<div class="gv-filter-spacer" />
|
<div class="gv-filter-spacer" />
|
||||||
<el-radio-group v-model="mode" size="small" @change="onModeChange">
|
<el-radio-group v-model="mode" size="small" @change="onModeChange">
|
||||||
@@ -1343,23 +1623,25 @@ onMounted(() => loadAll())
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="data.originGoodName" class="gt-row">
|
<div v-if="data.originGoodName" class="gt-row">
|
||||||
<div class="gt-label">原产品</div>
|
<div class="gt-label">{{ data.isCustom ? '来源' : '原产品' }}</div>
|
||||||
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
|
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<span class="good-name">{{ data.goodName }}</span>
|
<span class="good-name">{{ data.goodName }}</span>
|
||||||
|
<span v-if="data.mergedCount > 1" class="gv-merged-badge">×{{ data.mergedCount }}</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<span class="good-actions" @click.stop>
|
<span class="good-actions" @click.stop>
|
||||||
<el-button size="small" link :icon="Aim" title="定位原产品" @click="locateInRightTree(data.originGoodId)" />
|
<el-button v-if="!data.isCustom" size="small" link :icon="Aim" title="定位原产品" @click="locateInRightTree(data.originGoodId)" />
|
||||||
<el-button size="small" link :icon="Edit" @click="openEdit(data.raw)" />
|
<el-button size="small" link :icon="Edit" @click="openEdit(data.raw)" />
|
||||||
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
|
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="data.country || data.tags?.length || data.originDelisted" class="good-meta">
|
<div v-if="data.country || data.tags?.length || data.originDelisted" class="good-meta">
|
||||||
<span v-if="data.originDelisted" class="good-delisted-badge">下架</span>
|
<span v-if="data.originDelisted" class="good-delisted-badge">下架</span>
|
||||||
|
<span v-if="data.isCustom" class="good-tag">自定义</span>
|
||||||
<span v-if="data.country" class="good-country">{{ data.country }}</span>
|
<span v-if="data.country" class="good-country">{{ data.country }}</span>
|
||||||
<span
|
<span
|
||||||
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
|
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
|
||||||
@@ -1543,17 +1825,33 @@ onMounted(() => loadAll())
|
|||||||
<el-form label-width="80px" style="margin-top: 16px">
|
<el-form label-width="80px" style="margin-top: 16px">
|
||||||
<el-form-item label="国家">
|
<el-form-item label="国家">
|
||||||
<div v-if="mode === 'category' || !configDropTarget" class="select-inline">
|
<div v-if="mode === 'category' || !configDropTarget" class="select-inline">
|
||||||
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable @change="loadConfigPositions">
|
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable>
|
||||||
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) configForm.value.countryId = latest.id })" />
|
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries[allCountries.length - 1]; if (latest) configForm.countryId = latest.id })" />
|
||||||
</div>
|
</div>
|
||||||
<el-tag v-else>{{ allCountries.find(c => c.id === configForm.countryId)?.countryName }}</el-tag>
|
<el-tag v-else>{{ allCountries.find(c => c.id === configForm.countryId)?.countryName }}</el-tag>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="分类">
|
<el-form-item label="分类">
|
||||||
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
|
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
|
||||||
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
|
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="configSiblings.length" label="合并同名">
|
||||||
|
<div class="config-merge-box">
|
||||||
|
<div class="config-merge-tip">勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主源决定价格与详情。</div>
|
||||||
|
<div class="config-merge-primary">
|
||||||
|
<el-radio-group v-model="configPrimaryId">
|
||||||
|
<el-radio :value="String(configOG.rawId)">主源:{{ configOG.goodName }}</el-radio>
|
||||||
|
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<el-checkbox-group v-model="configChecked">
|
||||||
|
<el-checkbox v-for="s in configSiblings" :key="s.rawId" :value="String(s.rawId)">
|
||||||
|
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
|
||||||
|
</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="图片">
|
<el-form-item label="图片">
|
||||||
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
|
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -1564,14 +1862,9 @@ onMounted(() => loadAll())
|
|||||||
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||||
</el-option-group>
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) configForm.value.tagIds.push(latest.id) })" />
|
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) configForm.tagIds.push(latest.id) })" />
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="位置">
|
|
||||||
<el-select v-model="configForm.positionId" clearable placeholder="可选">
|
|
||||||
<el-option v-for="p in configPositions" :key="p.id" :label="`#${p.indexVal}`" :value="p.id" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="configVisible = false">取消</el-button>
|
<el-button @click="configVisible = false">取消</el-button>
|
||||||
@@ -1579,15 +1872,50 @@ onMounted(() => loadAll())
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Custom Good Modal -->
|
||||||
|
<el-dialog v-model="customVisible" title="新增自定义商品" width="620px" destroy-on-close>
|
||||||
|
<el-alert
|
||||||
|
title="自定义商品不关联 SDS 原产品,名称、图片、价格、详情、尺码、包装和 SKU 均可维护。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom:16px"
|
||||||
|
/>
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="商品名称" required><el-input v-model="customForm.goodName" /></el-form-item>
|
||||||
|
<el-form-item label="商品图片"><ImageUpload v-model="customForm.goodImage" label="上传图片" /></el-form-item>
|
||||||
|
<el-form-item label="基础价格"><el-input v-model="customForm.goodPrice" placeholder="例如 28.00" /></el-form-item>
|
||||||
|
<el-form-item label="国家" required>
|
||||||
|
<el-select v-model="customForm.countryId" filterable placeholder="请选择国家" style="width:100%">
|
||||||
|
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类" required>
|
||||||
|
<el-cascader v-model="customForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" style="width:100%" @change="onCustomCascaderChange" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签">
|
||||||
|
<el-select v-model="customForm.tagIds" multiple filterable placeholder="请选择标签" style="width:100%">
|
||||||
|
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
|
||||||
|
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||||
|
</el-option-group>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="优先级"><el-input-number v-model="customForm.goodPriority" :min="0" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="customVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="customLoading" @click="handleCustomCreate">创建并完善详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Edit Good Modal (direct edit — no separate detail step) -->
|
<!-- Edit Good Modal (direct edit — no separate detail step) -->
|
||||||
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
|
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
|
||||||
<!-- Origin product reference -->
|
<!-- Origin product reference -->
|
||||||
<div v-if="editGood?.originGood" class="edit-og-ref">
|
<div v-if="editGood?.originGood" class="edit-og-ref">
|
||||||
<el-image v-if="editGood.originGood.goodImage" :src="editGood.originGood.goodImage" fit="cover" class="edit-og-img" />
|
<el-image v-if="editGood.originGood.goodImage" :src="editGood.originGood.goodImage" fit="cover" class="edit-og-img" />
|
||||||
<div class="edit-og-meta">
|
<div class="edit-og-meta">
|
||||||
<div class="edit-og-label">关联原产品</div>
|
<div class="edit-og-label">{{ editIsCustom ? '自定义商品' : '关联原产品' }}</div>
|
||||||
<div class="edit-og-name">{{ editGood.originGood.goodName }}</div>
|
<div class="edit-og-name">{{ editGood.originGood.goodName }}</div>
|
||||||
<div class="edit-og-sub">SDS ID: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
|
<div class="edit-og-sub">{{ editIsCustom ? '自定义 ID' : 'SDS ID' }}: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
|
||||||
<div class="edit-og-status">
|
<div class="edit-og-status">
|
||||||
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
|
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
|
||||||
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
|
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
|
||||||
@@ -1601,6 +1929,7 @@ onMounted(() => loadAll())
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="!editIsCustom"
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
size="small"
|
size="small"
|
||||||
@@ -1609,6 +1938,31 @@ onMounted(() => loadAll())
|
|||||||
@click="handleSyncOneDetail"
|
@click="handleSyncOneDetail"
|
||||||
>同步详情</el-button>
|
>同步详情</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!editIsCustom" class="edit-merged-box">
|
||||||
|
<div class="edit-merged-title">
|
||||||
|
关联原产品(主源 + 副源)
|
||||||
|
<el-tooltip content="主源决定价格、详情与上下架;副源变体合并展示。切换主源后旧主源自动转为副源。">
|
||||||
|
<el-icon><QuestionFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="edit-merged-list">
|
||||||
|
<div class="edit-merged-item primary">
|
||||||
|
<span class="edit-merged-tag">主</span>
|
||||||
|
<span>{{ editGood?.originGood?.goodName }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="m in editMerged" :key="m.id" class="edit-merged-item">
|
||||||
|
<span class="edit-merged-tag sub">副</span>
|
||||||
|
<span>{{ m.goodName }}</span>
|
||||||
|
<el-button size="small" link type="primary" @click="promoteMerged(m)">设为主源</el-button>
|
||||||
|
<el-button size="small" link type="danger" @click="editMerged = editMerged.filter(x => x.id !== m.id)">移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-select v-model="editMergeSearch" filterable remote :remote-method="(q: string) => editMergeSearch = q"
|
||||||
|
placeholder="搜索原产品名称以添加副源(用于合并同名商品)" clearable style="width:100%">
|
||||||
|
<el-option v-for="c in editMergeCandidates" :key="c.rawId" :label="c.goodName" :value="String(c.rawId)"
|
||||||
|
@click="addEditMerge(c)" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
|
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
|
||||||
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
|
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
|
||||||
<el-form-item label="图片">
|
<el-form-item label="图片">
|
||||||
@@ -1616,14 +1970,14 @@ onMounted(() => loadAll())
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="国家">
|
<el-form-item label="国家">
|
||||||
<div class="select-inline">
|
<div class="select-inline">
|
||||||
<el-select v-model="editForm.countryId" filterable>
|
<el-select v-model="editForm.countryId" filterable placeholder="请选择国家">
|
||||||
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) editForm.value.countryId = latest.id })" />
|
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries[allCountries.length - 1]; if (latest) editForm.countryId = latest.id })" />
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="分类">
|
<el-form-item label="分类">
|
||||||
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" @change="onEditCascaderChange" />
|
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标签">
|
<el-form-item label="标签">
|
||||||
<div class="select-inline">
|
<div class="select-inline">
|
||||||
@@ -1632,14 +1986,38 @@ onMounted(() => loadAll())
|
|||||||
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||||
</el-option-group>
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) editForm.value.tagIds.push(latest.id) })" />
|
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) editForm.tagIds.push(latest.id) })" />
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="editIsCustom" label="基础价格">
|
||||||
|
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-tabs v-if="editOriginDetail" class="detail-tabs">
|
<el-tabs v-if="editOriginDetail" class="detail-tabs">
|
||||||
<el-tab-pane label="商品详情">
|
<el-tab-pane label="商品详情">
|
||||||
<el-descriptions :column="2" border size="small">
|
<el-form v-if="editIsCustom" label-width="100px" class="custom-detail-form">
|
||||||
|
<el-form-item label="商品编码"><el-input v-model="customContentForm.productCode" /></el-form-item>
|
||||||
|
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></el-form-item>
|
||||||
|
<el-form-item label="生产周期"><el-input-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
|
||||||
|
<el-form-item label="净重"><el-input v-model="customContentForm.minWeightG"><template #append>g</template></el-input></el-form-item>
|
||||||
|
<el-form-item label="生产工艺"><el-input v-model="customContentForm.productionProcess" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="材质"><el-input v-model="customContentForm.materialDescription" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="空白设计图"><el-input v-model="customContentForm.blankDesignUrl" /></el-form-item>
|
||||||
|
<el-form-item label="详情视频"><el-input v-model="customContentForm.detailsPageVideoUrl" /></el-form-item>
|
||||||
|
<el-form-item label="面料名称"><el-input v-model="customContentForm.textureName" /></el-form-item>
|
||||||
|
<el-form-item label="温馨提示"><el-input v-model="customContentForm.reminder" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="产品性能"><el-input v-model="customContentForm.productPerformance" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="适用场景"><el-input v-model="customContentForm.applicableScenarios" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="洗涤说明"><el-input v-model="customContentForm.washingInstructions" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="特殊说明"><el-input v-model="customContentForm.specialDescription" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="设计说明"><el-input v-model="customContentForm.designExplanation" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="设计区域"><el-input v-model="customContentForm.designArea" /></el-form-item>
|
||||||
|
<el-form-item label="图片要求"><el-input v-model="customContentForm.pictureRequest" type="textarea" /></el-form-item>
|
||||||
|
<el-form-item label="商品选项 JSON"><el-input v-model="customContentForm.optionsJson" type="textarea" :rows="6" /></el-form-item>
|
||||||
|
<el-form-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-descriptions v-else :column="2" border size="small">
|
||||||
<el-descriptions-item label="商品编码">{{ editOriginDetail.productCode || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="商品编码">{{ editOriginDetail.productCode || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="英文名称">{{ editOriginDetail.englishName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="英文名称">{{ editOriginDetail.englishName || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="生产周期">{{ editOriginDetail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
|
<el-descriptions-item label="生产周期">{{ editOriginDetail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
|
||||||
@@ -1649,7 +2027,8 @@ onMounted(() => loadAll())
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
|
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
|
||||||
<el-table :data="editSizeRows" border max-height="320">
|
<el-input v-if="editIsCustom" v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
|
||||||
|
<el-table v-else :data="editSizeRows" border max-height="320">
|
||||||
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
|
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
|
||||||
<el-table-column
|
<el-table-column
|
||||||
v-for="column in editSizeColumns"
|
v-for="column in editSizeColumns"
|
||||||
@@ -1661,7 +2040,8 @@ onMounted(() => loadAll())
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
|
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
|
||||||
<el-table :data="editPackageRows" border max-height="320">
|
<el-input v-if="editIsCustom" v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
|
||||||
|
<el-table v-else :data="editPackageRows" border max-height="320">
|
||||||
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
|
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
|
||||||
<el-table-column label="包装尺寸 (cm)" min-width="160">
|
<el-table-column label="包装尺寸 (cm)" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -1673,7 +2053,28 @@ onMounted(() => loadAll())
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="`SKU (${editVariants.length})`">
|
<el-tab-pane :label="`SKU (${editVariants.length})`">
|
||||||
<el-table :data="editVariants" border max-height="320">
|
<template v-if="editIsCustom">
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
|
||||||
|
<el-table :data="customContentForm.variants" border max-height="320">
|
||||||
|
<el-table-column label="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
|
||||||
|
<el-table-column label="尺码 ID" width="120"><template #default="{ row }"><el-input v-model="row.sizeId" /></template></el-table-column>
|
||||||
|
<el-table-column label="尺码" width="120"><template #default="{ row }"><el-input v-model="row.sizeName" /></template></el-table-column>
|
||||||
|
<el-table-column label="颜色 ID" width="120"><template #default="{ row }"><el-input v-model="row.colorId" /></template></el-table-column>
|
||||||
|
<el-table-column label="颜色" width="120"><template #default="{ row }"><el-input v-model="row.colorName" /></template></el-table-column>
|
||||||
|
<el-table-column label="色值" width="120"><template #default="{ row }"><el-input v-model="row.colorHex" placeholder="#FFFFFF" /></template></el-table-column>
|
||||||
|
<el-table-column label="图片" min-width="180"><template #default="{ row }"><el-input v-model="row.imageUrl" /></template></el-table-column>
|
||||||
|
<el-table-column label="价格" width="120"><template #default="{ row }"><el-input v-model="row.price" /></template></el-table-column>
|
||||||
|
<el-table-column label="原价" width="120"><template #default="{ row }"><el-input v-model="row.originalPrice" /></template></el-table-column>
|
||||||
|
<el-table-column label="重量(g)" width="120"><template #default="{ row }"><el-input v-model="row.weightG" /></template></el-table-column>
|
||||||
|
<el-table-column label="包装长(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxLengthCm" /></template></el-table-column>
|
||||||
|
<el-table-column label="包装宽(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxWidthCm" /></template></el-table-column>
|
||||||
|
<el-table-column label="包装高(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxHeightCm" /></template></el-table-column>
|
||||||
|
<el-table-column label="设计数据 JSON" min-width="220"><template #default="{ row }"><el-input v-model="row.designDataJson" type="textarea" :rows="2" /></template></el-table-column>
|
||||||
|
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<el-table v-else :data="editVariants" border max-height="320">
|
||||||
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
|
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
|
||||||
<el-table-column prop="sizeName" label="尺码" width="90" />
|
<el-table-column prop="sizeName" label="尺码" width="90" />
|
||||||
<el-table-column prop="colorName" label="颜色" width="100" />
|
<el-table-column prop="colorName" label="颜色" width="100" />
|
||||||
@@ -1908,10 +2309,21 @@ onMounted(() => loadAll())
|
|||||||
}
|
}
|
||||||
.good-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
.good-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||||
.good-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
.good-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||||||
.good-name-col { flex: 1; min-width: 0; overflow: hidden; }
|
|
||||||
.good-name {
|
.good-name {
|
||||||
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
font-size: 13px; line-height: 1.4; cursor: pointer;
|
font-size: 13px; line-height: 1.4; cursor: pointer;
|
||||||
|
max-width: calc(100% - 40px);
|
||||||
|
}
|
||||||
|
.good-name-col { flex: 1; min-width: 0; overflow: hidden; }
|
||||||
|
.gv-merged-badge {
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-color-primary-light-8);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.good-actions {
|
.good-actions {
|
||||||
display: flex; gap: 2px; flex-shrink: 0;
|
display: flex; gap: 2px; flex-shrink: 0;
|
||||||
@@ -2005,12 +2417,35 @@ onMounted(() => loadAll())
|
|||||||
.edit-og-sub { color: #909399; font-size: 12px; margin-top: 2px; }
|
.edit-og-sub { color: #909399; font-size: 12px; margin-top: 2px; }
|
||||||
.edit-og-meta { flex: 1; min-width: 0; }
|
.edit-og-meta { flex: 1; min-width: 0; }
|
||||||
.edit-og-status { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 6px; color: #909399; font-size: 12px; }
|
.edit-og-status { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 6px; color: #909399; font-size: 12px; }
|
||||||
|
.edit-merged-box { margin-top: 12px; }
|
||||||
|
.edit-merged-title {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 12px; color: #909399; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.edit-merged-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.edit-merged-item .el-button { margin-left: auto; }
|
||||||
|
.edit-merged-tag {
|
||||||
|
padding: 0 5px; border-radius: 4px; font-size: 11px; line-height: 18px;
|
||||||
|
background: var(--el-color-primary); color: #fff; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.edit-merged-tag.sub { background: var(--el-color-info); }
|
||||||
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
|
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
|
||||||
|
|
||||||
/* Config modal */
|
/* Config modal */
|
||||||
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
|
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
|
||||||
.config-og-name { font-weight: 600; font-size: 14px; }
|
.config-og-name { font-weight: 600; font-size: 14px; }
|
||||||
.config-og-meta { color: #909399; font-size: 12px; margin-top: 2px; }
|
.config-og-meta { color: #909399; font-size: 12px; margin-top: 2px; }
|
||||||
|
.config-merge-box { width: 100%; }
|
||||||
|
.config-merge-tip { color: #909399; font-size: 12px; margin-bottom: 8px; line-height: 1.5; }
|
||||||
|
.config-merge-primary {
|
||||||
|
padding: 8px 10px; background: #f5f7fa; border-radius: 6px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.config-merge-primary .el-radio-group { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||||
|
.config-merge-box .el-checkbox-group { display: flex; flex-direction: column; align-items: flex-start; max-height: 160px; overflow-y: auto; }
|
||||||
|
|
||||||
/* Tag mgmt */
|
/* Tag mgmt */
|
||||||
.tag-mgmt { max-height: 400px; overflow-y: auto; }
|
.tag-mgmt { max-height: 400px; overflow-y: auto; }
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
UpdatePositionRequest,
|
UpdatePositionRequest,
|
||||||
PositionFilter,
|
PositionFilter,
|
||||||
Country,
|
Country,
|
||||||
Category,
|
|
||||||
CategoryTree,
|
CategoryTree,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { positionsApi } from '@/api/positions'
|
import { positionsApi } from '@/api/positions'
|
||||||
@@ -41,7 +40,7 @@ async function loadLookups() {
|
|||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await positionsApi.getPositionsList(filter)
|
await positionsApi.getPositionsList(filter)
|
||||||
const data = await positionsApi.getPositionsList(filter) as any
|
const data = await positionsApi.getPositionsList(filter) as any
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
list.value = arr
|
list.value = arr
|
||||||
@@ -88,8 +87,6 @@ const dialogLoading = ref(false)
|
|||||||
|
|
||||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||||
indexVal: 0,
|
indexVal: 0,
|
||||||
countryId: '',
|
|
||||||
categoryId: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
@@ -218,17 +215,17 @@ onMounted(async () => {
|
|||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||||
<el-table-column label="国家" min-width="160">
|
<el-table-column label="国家" min-width="160">
|
||||||
<template #default="{ row }: { row: Position }">
|
<template #default="{ row }: any">
|
||||||
{{ row.country?.countryName || '-' }}
|
{{ row.country?.countryName || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="分类" min-width="200">
|
<el-table-column label="分类" min-width="200">
|
||||||
<template #default="{ row }: { row: Position }">
|
<template #default="{ row }: any">
|
||||||
{{ getCategoryName(row) }}
|
{{ getCategoryName(row) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: Position }">
|
<template #default="{ row }: any">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function refreshLogs() {
|
|||||||
|
|
||||||
function syncTypeLabel(type: SyncType): string {
|
function syncTypeLabel(type: SyncType): string {
|
||||||
if (type === 'CATEGORIES') return '分类'
|
if (type === 'CATEGORIES') return '分类'
|
||||||
if (type === 'PRODUCT_DETAILS') return '商品详情'
|
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
|
||||||
return '商品列表'
|
return '商品列表'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ async function doSync(type: SyncType) {
|
|||||||
const label = syncTypeLabel(type)
|
const label = syncTypeLabel(type)
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||||
'确认',
|
'确认',
|
||||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
@@ -136,7 +136,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="sync-action-title">数据同步</h2>
|
<h2 class="sync-action-title">数据同步</h2>
|
||||||
<p class="sync-action-desc">从上游 SDS 系统拉取最新分类和产品数据并同步到本地数据库</p>
|
<p class="sync-action-desc">分类和产品每小时自动同步;全部 SDS 原产品详情每天 03:30 自动同步,也可手动执行</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sync-action-buttons">
|
<div class="sync-action-buttons">
|
||||||
@@ -166,7 +166,7 @@ onUnmounted(() => {
|
|||||||
:icon="Refresh"
|
:icon="Refresh"
|
||||||
@click="handleSyncProductDetails"
|
@click="handleSyncProductDetails"
|
||||||
>
|
>
|
||||||
同步商品详情
|
同步全部原产品详情
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const filter = reactive<Required<TagFilter>>({
|
|||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await tagsApi.getTagsList(filter)
|
await tagsApi.getTagsList(filter)
|
||||||
const data = await tagsApi.getTagsList(filter) as any
|
const data = await tagsApi.getTagsList(filter) as any
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
list.value = arr
|
list.value = arr
|
||||||
@@ -62,7 +62,6 @@ const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: numbe
|
|||||||
tagColor: '#ff6800',
|
tagColor: '#ff6800',
|
||||||
tagFontColor: '#ffffff',
|
tagFontColor: '#ffffff',
|
||||||
timing: '',
|
timing: '',
|
||||||
tagGroupId: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
@@ -171,7 +170,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table-column label="预览" width="120">
|
<el-table-column label="预览" width="120">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<span
|
<span
|
||||||
v-if="row.tagColor"
|
v-if="row.tagColor"
|
||||||
class="tag-preview"
|
class="tag-preview"
|
||||||
@@ -182,7 +181,7 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||||
<el-table-column label="背景色" width="140">
|
<el-table-column label="背景色" width="140">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<div class="color-cell">
|
<div class="color-cell">
|
||||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||||
@@ -190,7 +189,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="字体色" width="140">
|
<el-table-column label="字体色" width="140">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<div class="color-cell">
|
<div class="color-cell">
|
||||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||||
@@ -198,14 +197,14 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="所属分组" min-width="140">
|
<el-table-column label="所属分组" min-width="140">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||||
<span v-else style="color: #999;">未分组</span>
|
<span v-else style="color: #999;">未分组</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/admin/',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
AutoImport({
|
AutoImport({
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Prisma connection string (PostgreSQL)
|
||||||
|
DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website
|
||||||
|
|
||||||
|
# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
|
||||||
|
# Must be at least 32 characters.
|
||||||
|
JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# Access token lifetime (e.g. 30m, 12h); short-lived, rotated via /auth/refresh
|
||||||
|
TOKEN_EXPIRES_IN=30m
|
||||||
|
|
||||||
|
# Refresh token lifetime (HttpOnly cookie)
|
||||||
|
REFRESH_TOKEN_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Comma-separated list of allowed CORS origins (leave empty to disable CORS)
|
||||||
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# Global rate limit per minute (per IP)
|
||||||
|
THROTTLE_LIMIT=120
|
||||||
|
|
||||||
|
PORT=3001
|
||||||
@@ -34,13 +34,16 @@
|
|||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
"@nestjs/schedule": "^4.0.0",
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.8.0",
|
||||||
"@types/multer": "^2.2.0",
|
"@types/multer": "^2.2.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -52,6 +55,7 @@
|
|||||||
"@nestjs/schematics": "^10.0.3",
|
"@nestjs/schematics": "^10.0.3",
|
||||||
"@nestjs/testing": "^10.3.0",
|
"@nestjs/testing": "^10.3.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "origin_goods" ADD COLUMN "delisted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Sync database with schema.prisma (missing columns/table from earlier iterations)
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "goods" ADD COLUMN "good_image" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tags" ADD COLUMN "tag_font_color" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "good_tags" (
|
||||||
|
"good_id" BIGINT NOT NULL,
|
||||||
|
"tag_id" BIGINT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "good_tags_pkey" PRIMARY KEY ("good_id","tag_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "good_tags_tag_id_idx" ON "good_tags"("tag_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TYPE "OriginGoodSource" AS ENUM ('SDS', 'CUSTOM');
|
||||||
|
|
||||||
|
ALTER TABLE "origin_goods"
|
||||||
|
ADD COLUMN "source" "OriginGoodSource" NOT NULL DEFAULT 'SDS';
|
||||||
|
|
||||||
|
CREATE INDEX "origin_goods_source_idx" ON "origin_goods"("source");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Create enum for user roles
|
||||||
|
CREATE TYPE "Role" AS ENUM ('ADMIN');
|
||||||
|
|
||||||
|
-- Add role column, existing users become ADMIN
|
||||||
|
ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'ADMIN';
|
||||||
|
|
||||||
|
-- Token version for JWT revocation (logout bumps it)
|
||||||
|
ALTER TABLE "users" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "good_origin_goods" (
|
||||||
|
"good_id" BIGINT NOT NULL,
|
||||||
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "good_origin_goods_pkey" PRIMARY KEY ("good_id","origin_good_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -14,6 +15,11 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Origin Goods ----------
|
// ---------- Origin Goods ----------
|
||||||
|
enum OriginGoodSource {
|
||||||
|
SDS
|
||||||
|
CUSTOM
|
||||||
|
}
|
||||||
|
|
||||||
model OriginGood {
|
model OriginGood {
|
||||||
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
||||||
sdsGoodId String @unique @map("sds_good_id")
|
sdsGoodId String @unique @map("sds_good_id")
|
||||||
@@ -23,14 +29,17 @@ model OriginGood {
|
|||||||
goodImage String? @map("good_image")
|
goodImage String? @map("good_image")
|
||||||
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||||
delisted Boolean @default(false)
|
delisted Boolean @default(false)
|
||||||
|
source OriginGoodSource @default(SDS)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
goods Good[]
|
goods Good[]
|
||||||
detail OriginGoodDetail?
|
detail OriginGoodDetail?
|
||||||
variants OriginGoodVariant[]
|
variants OriginGoodVariant[]
|
||||||
|
mergedIntoGoods GoodOriginGood[]
|
||||||
|
|
||||||
@@index([sdsCategoryId])
|
@@index([sdsCategoryId])
|
||||||
|
@@index([source])
|
||||||
@@map("origin_goods")
|
@@map("origin_goods")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +216,7 @@ model Good {
|
|||||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
goodTags GoodTag[]
|
goodTags GoodTag[]
|
||||||
|
mergedOriginGoods GoodOriginGood[]
|
||||||
|
|
||||||
@@index([originGoodId])
|
@@index([originGoodId])
|
||||||
@@index([countryId])
|
@@index([countryId])
|
||||||
@@ -233,11 +243,33 @@ model GoodTag {
|
|||||||
@@map("good_tags")
|
@@map("good_tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Good-OriginGood Junction (merged secondary sources, M:N) ----------
|
||||||
|
// Primary source stays on goods.origin_good_id and is NOT stored here.
|
||||||
|
model GoodOriginGood {
|
||||||
|
goodId BigInt @map("good_id")
|
||||||
|
originGoodId BigInt @map("origin_good_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@id([goodId, originGoodId])
|
||||||
|
@@index([originGoodId])
|
||||||
|
@@map("good_origin_goods")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Users (admin authentication) ----------
|
// ---------- Users (admin authentication) ----------
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
|
role Role @default(ADMIN)
|
||||||
|
// Bumped on logout / revocation; JWTs carrying an older version are rejected.
|
||||||
|
tokenVersion Int @default(0) @map("token_version")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Export all application tables from the local (source) database to a JSON
|
||||||
|
* file, preserving column types for the matching import-data.mjs script.
|
||||||
|
*
|
||||||
|
* Usage (from apps/api, against the local DB in .env):
|
||||||
|
* node scripts/export-data.mjs <output.json>
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const TABLES = [
|
||||||
|
'users',
|
||||||
|
'countries',
|
||||||
|
'categories',
|
||||||
|
'tag_groups',
|
||||||
|
'tags',
|
||||||
|
'positions',
|
||||||
|
'origin_goods',
|
||||||
|
'origin_good_variants',
|
||||||
|
'origin_good_details',
|
||||||
|
'goods',
|
||||||
|
'good_tags',
|
||||||
|
'sync_logs',
|
||||||
|
];
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Serialize values losslessly; import side uses information_schema to restore types.
|
||||||
|
function serialize(value) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === 'bigint') return value.toString();
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === 'object' && Buffer.isBuffer(value)) return value.toString('base64');
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value); // jsonb
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const out = process.argv[2];
|
||||||
|
if (!out) {
|
||||||
|
console.error('Usage: node scripts/export-data.mjs <output.json>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dump = { exportedAt: new Date().toISOString(), tables: {} };
|
||||||
|
for (const table of TABLES) {
|
||||||
|
const rows = await prisma.$queryRawUnsafe(`SELECT * FROM "${table}"`);
|
||||||
|
dump.tables[table] = rows.map((row) => {
|
||||||
|
const o = {};
|
||||||
|
for (const [k, v] of Object.entries(row)) o[k] = serialize(v);
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
console.log(`${table}: ${rows.length} rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(out, JSON.stringify(dump));
|
||||||
|
console.log(`Wrote ${out}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Import a dump produced by export-data.mjs into the current database.
|
||||||
|
* Tables are truncated first (order-independent via session_replication_role)
|
||||||
|
* and columns are cast back to their real types using information_schema.
|
||||||
|
*
|
||||||
|
* Usage (inside the api container):
|
||||||
|
* node scripts/import-data.mjs <dump.json>
|
||||||
|
*/
|
||||||
|
import { PrismaClient, Prisma } from '@prisma/client';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function toLiteral(value, udtName) {
|
||||||
|
if (value === null) return Prisma.sql`NULL`;
|
||||||
|
const target = Prisma.raw(`"${udtName}"`);
|
||||||
|
switch (udtName) {
|
||||||
|
case 'int2':
|
||||||
|
case 'int4':
|
||||||
|
case 'int8':
|
||||||
|
return Prisma.sql`${BigInt(value)}::${target}`;
|
||||||
|
case 'float4':
|
||||||
|
case 'float8':
|
||||||
|
case 'numeric':
|
||||||
|
return Prisma.sql`${Number(value)}::${target}`;
|
||||||
|
case 'bool':
|
||||||
|
return Prisma.sql`${!!value}::${target}`;
|
||||||
|
case 'timestamptz':
|
||||||
|
case 'timestamp':
|
||||||
|
return Prisma.sql`${new Date(value).toISOString()}::${target}`;
|
||||||
|
case 'date':
|
||||||
|
return Prisma.sql`${String(value)}::${target}`;
|
||||||
|
case 'jsonb':
|
||||||
|
case 'json':
|
||||||
|
return Prisma.sql`${typeof value === 'string' ? value : JSON.stringify(value)}::${target}`;
|
||||||
|
case 'bytea':
|
||||||
|
return Prisma.sql`${Buffer.from(value, 'base64')}::bytea`;
|
||||||
|
default:
|
||||||
|
// text, varchar, enums and anything else: pass as text and cast
|
||||||
|
return Prisma.sql`${String(value)}::${target}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const file = process.argv[2];
|
||||||
|
if (!file) {
|
||||||
|
console.error('Usage: node scripts/import-data.mjs <dump.json>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const dump = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
|
||||||
|
// Suspend FK checks during bulk load (postgres superuser not required for
|
||||||
|
// session_replication_role in the compose postgres where app user owns db).
|
||||||
|
await prisma.$executeRawUnsafe(`SET session_replication_role = replica`);
|
||||||
|
|
||||||
|
const summary = {};
|
||||||
|
for (const [table, rows] of Object.entries(dump.tables)) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
summary[table] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
|
||||||
|
|
||||||
|
const colTypes = {};
|
||||||
|
const info = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT column_name, udt_name FROM information_schema.columns WHERE table_name = '${table}'`,
|
||||||
|
);
|
||||||
|
for (const c of info) colTypes[c.column_name] = c.udt_name;
|
||||||
|
|
||||||
|
const columns = Object.keys(rows[0]);
|
||||||
|
const colList = Prisma.raw(columns.map((c) => `"${c}"`).join(', '));
|
||||||
|
const CHUNK = 200;
|
||||||
|
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||||
|
const tuples = rows.slice(i, i + CHUNK).map(
|
||||||
|
(r) =>
|
||||||
|
Prisma.sql`(${Prisma.join(
|
||||||
|
columns.map((c) => toLiteral(r[c], colTypes[c])),
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
await prisma.$executeRaw(
|
||||||
|
Prisma.sql`INSERT INTO ${Prisma.raw(`"${table}"`)} (${colList}) VALUES ${Prisma.join(tuples)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Keep sequences ahead of imported serial ids
|
||||||
|
// Keep serial sequences ahead of imported ids
|
||||||
|
const idCol = columns.find(
|
||||||
|
(c) => c === 'id' || c === `${table.replace(/s$/, '')}_id`,
|
||||||
|
);
|
||||||
|
if (idCol) {
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`SELECT setval(pg_get_serial_sequence('"${table}"', '${idCol}'), COALESCE((SELECT MAX("${idCol}") FROM "${table}"), 1))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
summary[table] = rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(`SET session_replication_role = DEFAULT`);
|
||||||
|
console.log('Imported:', JSON.stringify(summary, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Set the admin credentials for the production deployment:
|
||||||
|
* rename/disable any existing admin and create/update user `inkreach`.
|
||||||
|
*
|
||||||
|
* Usage (inside the api container): node scripts/set-admin.mjs
|
||||||
|
* Reads NEW_ADMIN_USER / NEW_ADMIN_PASSWORD from env.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const username = process.env.NEW_ADMIN_USER;
|
||||||
|
const password = process.env.NEW_ADMIN_PASSWORD;
|
||||||
|
if (!username || !password) {
|
||||||
|
console.error('NEW_ADMIN_USER / NEW_ADMIN_PASSWORD must be set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { username },
|
||||||
|
create: { username, passwordHash },
|
||||||
|
update: { passwordHash, tokenVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove every other admin so only `inkreach` can sign in.
|
||||||
|
const others = await prisma.user.deleteMany({
|
||||||
|
where: { username: { not: username } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = await prisma.user.count();
|
||||||
|
console.log(`Admin '${username}' set. Demoted ${others.count} other user(s). Total users: ${total}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { RolesGuard } from './auth/guards/roles.guard';
|
||||||
import { CountriesModule } from './countries/countries.module';
|
import { CountriesModule } from './countries/countries.module';
|
||||||
import { CategoriesModule } from './categories/categories.module';
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
import { TagsModule } from './tags/tags.module';
|
import { TagsModule } from './tags/tags.module';
|
||||||
@@ -18,6 +21,14 @@ import { UploadModule } from './upload/upload.module';
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
|
// Global rate limiting: 120 req/min per IP. Stricter limits are set
|
||||||
|
// per-endpoint with @Throttle (auth, upload).
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60_000,
|
||||||
|
limit: Number(process.env.THROTTLE_LIMIT ?? 120),
|
||||||
|
},
|
||||||
|
]),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CountriesModule,
|
CountriesModule,
|
||||||
@@ -31,5 +42,17 @@ import { UploadModule } from './upload/upload.module';
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
UploadModule,
|
UploadModule,
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Enforces the ADMIN role on every authenticated route unless the
|
||||||
|
// route widens access with @Roles(...).
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: RolesGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
ApiOperation,
|
Body,
|
||||||
ApiResponse,
|
Controller,
|
||||||
ApiTags,
|
Get,
|
||||||
} from '@nestjs/swagger';
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import {
|
import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
|
||||||
LoginResponseDto,
|
import type { AuthenticatedUser } from './strategies/jwt.strategy';
|
||||||
UserPublicDto,
|
|
||||||
} from './dto/auth-response.dto';
|
const ACCESS_TOKEN_COOKIE = 'ir_at';
|
||||||
|
const REFRESH_TOKEN_COOKIE = 'ir_rt';
|
||||||
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -19,19 +31,102 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Register a new admin user' })
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
|
@ApiOperation({ summary: 'Register the first admin user (bootstrap only)' })
|
||||||
@ApiResponse({ status: 201, type: UserPublicDto })
|
@ApiResponse({ status: 201, type: UserPublicDto })
|
||||||
@ApiResponse({ status: 409, description: 'Username already exists' })
|
@ApiResponse({ status: 409, description: 'Username already exists' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' })
|
||||||
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
|
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
|
||||||
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
|
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Login and obtain a JWT' })
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
|
@ApiOperation({ summary: 'Login and obtain access + refresh tokens' })
|
||||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
|
async login(
|
||||||
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
|
@Body() dto: LoginDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<LoginResponseDto> {
|
||||||
|
const result = await this.authService.login(dto);
|
||||||
|
// HttpOnly cookies are the primary session channel for the admin SPA
|
||||||
|
// (XSS cannot read them). The access token is also returned in the
|
||||||
|
// body for non-browser API clients.
|
||||||
|
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
// Only ever sent to /auth/refresh and /auth/logout
|
||||||
|
path: '/auth',
|
||||||
|
});
|
||||||
|
// The refresh token deliberately stays HttpOnly-only.
|
||||||
|
return {
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
user: result.user,
|
||||||
|
} as unknown as LoginResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
|
@ApiOperation({ summary: 'Rotate the refresh token cookie' })
|
||||||
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
|
||||||
|
async refresh(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<LoginResponseDto> {
|
||||||
|
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
|
||||||
|
if (!token) {
|
||||||
|
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
||||||
|
throw new UnauthorizedException('Missing refresh token');
|
||||||
|
}
|
||||||
|
const result = await this.authService.refresh(token);
|
||||||
|
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
path: '/auth',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
user: result.user,
|
||||||
|
} as unknown as LoginResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiOperation({ summary: 'Current authenticated user' })
|
||||||
|
@ApiResponse({ status: 200, type: UserPublicDto })
|
||||||
|
me(@Req() req: Request & { user: AuthenticatedUser }): Promise<UserPublicDto> {
|
||||||
|
return this.authService.me(req.user.id) as unknown as Promise<UserPublicDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiOperation({ summary: 'Revoke all tokens of the current user' })
|
||||||
|
async logout(
|
||||||
|
@Req() req: Request & { user: AuthenticatedUser },
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
await this.authService.logout(req.user.id);
|
||||||
|
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||||||
|
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('JWT_SECRET must be configured');
|
throw new Error('JWT_SECRET must be configured');
|
||||||
}
|
}
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error('JWT_SECRET must be at least 32 characters (use a strong random value)');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
secret,
|
secret,
|
||||||
signOptions: { expiresIn: '7d' },
|
signOptions: {
|
||||||
|
expiresIn: config.get<string>('TOKEN_EXPIRES_IN') ?? '7d',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,102 +1,167 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
let prisma: PrismaService;
|
let jwt: JwtService;
|
||||||
const createdUsernames: string[] = [];
|
let prisma: {
|
||||||
|
user: {
|
||||||
|
count: jest.Mock;
|
||||||
|
findUnique: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const HASH = bcrypt.hashSync('plain-pwd', 10);
|
||||||
|
const dbUser = {
|
||||||
|
id: 1n,
|
||||||
|
username: 'alice',
|
||||||
|
passwordHash: HASH,
|
||||||
|
role: 'ADMIN' as const,
|
||||||
|
tokenVersion: 0,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
prisma = {
|
||||||
|
user: {
|
||||||
|
count: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: 'test-secret',
|
secret: 'a'.repeat(32),
|
||||||
signOptions: { expiresIn: '1h' },
|
signOptions: { expiresIn: '1h' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, PrismaService],
|
providers: [AuthService, { provide: PrismaService, useValue: prisma }],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = moduleRef.get(AuthService);
|
service = moduleRef.get(AuthService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
jwt = moduleRef.get(JwtService);
|
||||||
await prisma.onModuleInit();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
beforeEach(() => {
|
||||||
// Cleanup created test users
|
jest.clearAllMocks();
|
||||||
if (createdUsernames.length) {
|
|
||||||
await prisma.user.deleteMany({
|
|
||||||
where: { username: { in: createdUsernames } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await prisma.onModuleDestroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('register', () => {
|
describe('register', () => {
|
||||||
it('creates a new user and stores a hashed password', async () => {
|
it('creates the first user with a hashed password', async () => {
|
||||||
const username = `test_reg_${Date.now()}`;
|
prisma.user.count.mockResolvedValueOnce(0);
|
||||||
createdUsernames.push(username);
|
prisma.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
prisma.user.create.mockResolvedValueOnce(dbUser);
|
||||||
|
|
||||||
const user = await service.register({ username, password: 'plain-pwd' });
|
const user = await service.register({ username: 'alice', password: 'plain-pwd' });
|
||||||
|
|
||||||
expect(user.username).toBe(username);
|
expect(user.username).toBe('alice');
|
||||||
expect(user.id).toBeTruthy();
|
const created = prisma.user.create.mock.calls[0][0].data;
|
||||||
|
expect(created.passwordHash).not.toBe('plain-pwd');
|
||||||
|
await expect(bcrypt.compare('plain-pwd', created.passwordHash)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
const stored = await prisma.user.findUnique({ where: { username } });
|
it('refuses registration once a user exists (bootstrap lock)', async () => {
|
||||||
expect(stored).not.toBeNull();
|
prisma.user.count.mockResolvedValueOnce(1);
|
||||||
expect(stored?.passwordHash).not.toBe('plain-pwd');
|
await expect(
|
||||||
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
|
service.register({ username: 'mallory', password: 'evil-pwd' }),
|
||||||
expect(matches).toBe(true);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws ConflictException for duplicate usernames', async () => {
|
it('throws ConflictException for duplicate usernames', async () => {
|
||||||
const username = `test_dup_${Date.now()}`;
|
prisma.user.count.mockResolvedValueOnce(0);
|
||||||
createdUsernames.push(username);
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
|
|
||||||
await service.register({ username, password: 'pwd1234' });
|
|
||||||
await expect(
|
await expect(
|
||||||
service.register({ username, password: 'pwd5678' }),
|
service.register({ username: 'alice', password: 'pwd5678' }),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('returns an access token for valid credentials', async () => {
|
it('returns access + refresh tokens with tokenVersion and type', async () => {
|
||||||
const username = `test_login_${Date.now()}`;
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
createdUsernames.push(username);
|
const result = await service.login({ username: 'alice', password: 'plain-pwd' });
|
||||||
await service.register({ username, password: 'correct-pwd' });
|
|
||||||
|
|
||||||
const result = await service.login({ username, password: 'correct-pwd' });
|
expect(result.user.username).toBe('alice');
|
||||||
expect(result.accessToken).toEqual(expect.any(String));
|
const access = jwt.decode(result.accessToken) as Record<string, unknown>;
|
||||||
const parts = result.accessToken.split('.');
|
expect(access.typ).toBe('access');
|
||||||
expect(parts.length).toBe(3);
|
expect(access.tv).toBe(0);
|
||||||
expect(result.user.username).toBe(username);
|
const refresh = jwt.decode(result.refreshToken) as Record<string, unknown>;
|
||||||
|
expect(refresh.typ).toBe('refresh');
|
||||||
|
expect(refresh.tv).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws UnauthorizedException for wrong password', async () => {
|
it('throws UnauthorizedException for wrong password', async () => {
|
||||||
const username = `test_wrong_${Date.now()}`;
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
createdUsernames.push(username);
|
|
||||||
await service.register({ username, password: 'right-pwd' });
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.login({ username, password: 'wrong-pwd' }),
|
service.login({ username: 'alice', password: 'wrong-pwd' }),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws UnauthorizedException for unknown user', async () => {
|
it('throws UnauthorizedException for unknown user', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(null);
|
||||||
await expect(
|
await expect(
|
||||||
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
|
service.login({ username: 'no-such-user', password: 'whatever' }),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('rotates a valid refresh token', async () => {
|
||||||
|
const refreshToken = await jwt.signAsync({
|
||||||
|
sub: '1',
|
||||||
|
username: 'alice',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tv: 0,
|
||||||
|
typ: 'refresh',
|
||||||
|
});
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
|
|
||||||
|
const result = await service.refresh(refreshToken);
|
||||||
|
expect(result.user.username).toBe('alice');
|
||||||
|
expect(result.accessToken).not.toBe(refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects access tokens used as refresh tokens', async () => {
|
||||||
|
const accessToken = await jwt.signAsync({
|
||||||
|
sub: '1',
|
||||||
|
username: 'alice',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tv: 0,
|
||||||
|
typ: 'access',
|
||||||
|
});
|
||||||
|
await expect(service.refresh(accessToken)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects refresh tokens with a stale tokenVersion (revoked)', async () => {
|
||||||
|
const refreshToken = await jwt.signAsync({
|
||||||
|
sub: '1',
|
||||||
|
username: 'alice',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tv: 0,
|
||||||
|
typ: 'refresh',
|
||||||
|
});
|
||||||
|
// User logged out elsewhere: tokenVersion bumped to 1
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
|
||||||
|
await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('bumps tokenVersion to revoke all tokens', async () => {
|
||||||
|
prisma.user.update.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
|
||||||
|
await service.logout(1n);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 1n },
|
||||||
|
data: { tokenVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -13,16 +14,25 @@ import type { JwtPayload } from './strategies/jwt.strategy';
|
|||||||
export interface PublicUser {
|
export interface PublicUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
role: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BCRYPT_ROUNDS = 10;
|
const BCRYPT_ROUNDS = 10;
|
||||||
const TOKEN_EXPIRES_IN = '7d';
|
const ACCESS_TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '30m';
|
||||||
|
const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compared against when the username does not exist so that login takes
|
||||||
|
* the same time either way (prevents user enumeration via timing).
|
||||||
|
*/
|
||||||
|
const DUMMY_HASH = '$2b$10$l232BFW3u63Mhfx0BatxUOLtw.qEofG9fNYjLsh2zce7MdIKDAIR6';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -32,10 +42,15 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a brand-new admin user. Throws {@link ConflictException}
|
* Bootstrap-only registration: allowed just while the instance has no
|
||||||
* if the username is already taken.
|
* users. Once an admin exists the endpoint refuses to create accounts
|
||||||
|
* (use database seeding / an operator flow instead).
|
||||||
*/
|
*/
|
||||||
async register(dto: RegisterDto): Promise<PublicUser> {
|
async register(dto: RegisterDto): Promise<PublicUser> {
|
||||||
|
const userCount = await this.prisma.user.count();
|
||||||
|
if (userCount > 0) {
|
||||||
|
throw new ForbiddenException('Registration is disabled');
|
||||||
|
}
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { username: dto.username },
|
where: { username: dto.username },
|
||||||
});
|
});
|
||||||
@@ -50,37 +65,118 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies credentials and returns a signed JWT.
|
* Verifies credentials and returns signed access + refresh tokens.
|
||||||
|
* Both tokens embed the user's tokenVersion so bumping it on the user
|
||||||
|
* row (logout / revocation) invalidates them immediately.
|
||||||
*/
|
*/
|
||||||
async login(dto: LoginDto): Promise<LoginResult> {
|
async login(dto: LoginDto): Promise<LoginResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { username: dto.username },
|
where: { username: dto.username },
|
||||||
});
|
});
|
||||||
|
// Always run a bcrypt compare (against a dummy hash when the user is
|
||||||
|
// unknown) so response timing cannot be used to enumerate usernames.
|
||||||
|
const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH);
|
||||||
|
if (!user || !ok) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken: await this.signAccessToken(user),
|
||||||
|
refreshToken: await this.signRefreshToken(user),
|
||||||
|
user: this.toPublic(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates a refresh token: the old refresh token becomes invalid as
|
||||||
|
* soon as tokenVersion is bumped (logout, revocation).
|
||||||
|
*/
|
||||||
|
async refresh(refreshToken: string): Promise<LoginResult> {
|
||||||
|
let payload: JwtPayload;
|
||||||
|
try {
|
||||||
|
payload = await this.jwt.verifyAsync(refreshToken);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
if (payload.typ !== 'refresh') {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
const user = await this.prisma.user
|
||||||
|
.findUnique({ where: { id: BigInt(payload.sub) } })
|
||||||
|
.catch(() => null);
|
||||||
|
if (!user || user.tokenVersion !== payload.tv) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken: await this.signAccessToken(user),
|
||||||
|
refreshToken: await this.signRefreshToken(user),
|
||||||
|
user: this.toPublic(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes all tokens of a user by bumping tokenVersion.
|
||||||
|
*/
|
||||||
|
async logout(userId: bigint): Promise<void> {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { tokenVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(userId: bigint): Promise<PublicUser> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
return this.toPublic(user);
|
||||||
if (!ok) {
|
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async signAccessToken(user: {
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}): Promise<string> {
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
sub: user.id.toString(),
|
sub: user.id.toString(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
tv: user.tokenVersion,
|
||||||
|
typ: 'access',
|
||||||
};
|
};
|
||||||
const accessToken = await this.jwt.signAsync(payload, {
|
return this.jwt.signAsync(payload, {
|
||||||
expiresIn: TOKEN_EXPIRES_IN,
|
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async signRefreshToken(user: {
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}): Promise<string> {
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
tv: user.tokenVersion,
|
||||||
|
typ: 'refresh',
|
||||||
|
};
|
||||||
|
return this.jwt.signAsync(payload, {
|
||||||
|
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
||||||
});
|
});
|
||||||
return { accessToken, user: this.toPublic(user) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublic(user: {
|
private toPublic(user: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
username: string;
|
username: string;
|
||||||
|
role: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): PublicUser {
|
}): PublicUser {
|
||||||
return {
|
return {
|
||||||
id: user.id.toString(),
|
id: user.id.toString(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricts a route to the given roles. When omitted, any
|
||||||
|
* authenticated user with an ADMIN role passes the RolesGuard.
|
||||||
|
*/
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { RolesGuard } from './roles.guard';
|
||||||
|
|
||||||
|
describe('RolesGuard', () => {
|
||||||
|
let guard: RolesGuard;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
guard = new RolesGuard(new Reflector());
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = (user: unknown, roles?: string[]) =>
|
||||||
|
({
|
||||||
|
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||||
|
getHandler: () => (roles ? { __roles: roles } : {}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
it('passes public routes (no authenticated user)', () => {
|
||||||
|
expect(guard.canActivate(context(undefined))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes ADMIN users by default', () => {
|
||||||
|
expect(guard.canActivate(context({ id: 1n, username: 'a', role: 'ADMIN' }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks users without the ADMIN role', () => {
|
||||||
|
expect(() => guard.canActivate(context({ id: 1n, username: 'a', role: 'VIEWER' }))).toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../strategies/jwt.strategy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-based access control. Applied globally: any authenticated user
|
||||||
|
* reaching a protected route must hold the ADMIN role unless the route
|
||||||
|
* declares a wider set with @Roles(...). Routes without a JwtAuthGuard
|
||||||
|
* (public endpoints) have no `request.user` and are skipped here — their
|
||||||
|
* openness is decided by the controller's own guards.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<{
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
}>();
|
||||||
|
if (!request.user) {
|
||||||
|
return true; // public route — no JwtAuthGuard in front
|
||||||
|
}
|
||||||
|
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]) ?? ['ADMIN'];
|
||||||
|
if (!required.includes(request.user.role)) {
|
||||||
|
throw new ForbiddenException('Insufficient role');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,51 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export const ACCESS_TOKEN_COOKIE = 'ir_at';
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape of the JWT we issue.
|
* Shape of the JWT we issue.
|
||||||
*
|
*
|
||||||
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
|
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
|
||||||
|
* `typ` distinguishes access tokens from refresh tokens; `tv` is the user's
|
||||||
|
* tokenVersion and `role` drives the RolesGuard.
|
||||||
*/
|
*/
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
role?: string;
|
||||||
|
tv?: number;
|
||||||
|
typ?: 'access' | 'refresh';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(config: ConfigService) {
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
const secret = config.get<string>('JWT_SECRET');
|
const secret = config.get<string>('JWT_SECRET');
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('JWT_SECRET is not configured');
|
throw new Error('JWT_SECRET is not configured');
|
||||||
}
|
}
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error('JWT_SECRET must be at least 32 characters');
|
||||||
|
}
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
// Access tokens are accepted from the HttpOnly cookie (browser) or
|
||||||
|
// the Authorization header (non-browser API clients).
|
||||||
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
|
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
(req) => req?.cookies?.[ACCESS_TOKEN_COOKIE] ?? null,
|
||||||
|
]),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: secret,
|
secretOrKey: secret,
|
||||||
});
|
});
|
||||||
@@ -29,12 +54,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs on every authenticated request. The returned object becomes
|
* Runs on every authenticated request. The returned object becomes
|
||||||
* `request.user` for downstream controllers.
|
* `request.user` for downstream controllers. The user and its
|
||||||
|
* tokenVersion are re-checked in the database so tokens of deleted
|
||||||
|
* users, logged-out users, or refresh tokens stop working immediately.
|
||||||
*/
|
*/
|
||||||
validate(payload: JwtPayload): { id: bigint; username: string } {
|
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
|
||||||
if (!payload?.sub || !payload.username) {
|
if (!payload?.sub || !payload.username) {
|
||||||
throw new UnauthorizedException('Invalid token payload');
|
throw new UnauthorizedException('Invalid token payload');
|
||||||
}
|
}
|
||||||
return { id: BigInt(payload.sub), username: payload.username };
|
// Refresh tokens must never be accepted as API credentials.
|
||||||
|
if (payload.typ === 'refresh') {
|
||||||
|
throw new UnauthorizedException('Invalid token type');
|
||||||
|
}
|
||||||
|
const user = await this.prisma.user
|
||||||
|
.findUnique({ where: { id: BigInt(payload.sub) } })
|
||||||
|
.catch(() => null);
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
user.username !== payload.username ||
|
||||||
|
(payload.tv !== undefined && user.tokenVersion !== payload.tv)
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
return { id: user.id, username: user.username, role: user.role };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,24 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
? exception.getStatus()
|
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
// Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
|
||||||
|
// errors — map them to 400 instead of leaking a 500.
|
||||||
|
if (
|
||||||
|
status === HttpStatus.INTERNAL_SERVER_ERROR &&
|
||||||
|
exception instanceof Error &&
|
||||||
|
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
|
||||||
|
) {
|
||||||
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
message: 'Invalid numeric identifier',
|
||||||
|
error: 'BadRequestError',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let message: string | string[] = 'Internal server error';
|
let message: string | string[] = 'Internal server error';
|
||||||
let error = 'InternalServerError';
|
let error = 'InternalServerError';
|
||||||
@@ -51,11 +66,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
message = exception.message;
|
message = exception.message;
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
message = exception.message;
|
// Unexpected errors (Prisma, driver, ...) may contain SQL or
|
||||||
error = exception.name;
|
// connection details — never send them to the client.
|
||||||
|
this.logger.error(
|
||||||
|
`${request.method} ${request.url} -> ${status} ${exception.message}`,
|
||||||
|
exception.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status >= 500) {
|
if (status >= 500 && exception instanceof HttpException) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${request.method} ${request.url} -> ${status} ${message}`,
|
`${request.method} ${request.url} -> ${status} ${message}`,
|
||||||
exception instanceof Error ? exception.stack : undefined,
|
exception instanceof Error ? exception.stack : undefined,
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export class BatchCreateItemDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId!: number;
|
originGoodId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export class CreateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId!: number;
|
originGoodId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsNumberString,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { CreateGoodDto } from './create-good.dto';
|
||||||
|
|
||||||
|
export class CustomGoodDetailDto {
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productCode?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
englishName?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
blankDesignUrl?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
detailsPageVideoUrl?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
textureName?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
productionCycleHours?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, example: '208.000' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
minWeightG?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productionProcess?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
materialDescription?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reminder?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productPerformance?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
applicableScenarios?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
washingInstructions?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
specialDescription?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
designExplanation?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
designArea?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
pictureRequest?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
sizeChart?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
packageSpecs?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
options?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
media?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomGoodVariantDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
sku!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sizeName?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sizeId?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
colorName?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
colorHex?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
colorId?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, example: '28.00' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
price?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
originalPrice?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
weightG?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
boxLengthCm?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
boxWidthCm?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
boxHeightCm?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
designData?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [
|
||||||
|
'originGoodId',
|
||||||
|
] as const) {
|
||||||
|
@ApiProperty({ required: false, nullable: true, example: '28.00' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
goodPrice?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: CustomGoodDetailDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => CustomGoodDetailDto)
|
||||||
|
detail?: CustomGoodDetailDto;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [CustomGoodVariantDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CustomGoodVariantDto)
|
||||||
|
variants?: CustomGoodVariantDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCustomGoodContentDto extends PartialType(
|
||||||
|
OmitType(CreateCustomGoodDto, [
|
||||||
|
'countryId',
|
||||||
|
'categoryId',
|
||||||
|
'tagIds',
|
||||||
|
'positionId',
|
||||||
|
'goodPriority',
|
||||||
|
] as const),
|
||||||
|
) {}
|
||||||
@@ -9,6 +9,7 @@ export interface GoodRelations {
|
|||||||
originGood?: {
|
originGood?: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
sdsGoodId: string;
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
goodName: string | null;
|
goodName: string | null;
|
||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: unknown;
|
goodPrice: unknown;
|
||||||
@@ -32,6 +33,31 @@ export interface GoodRelations {
|
|||||||
_count?: { variants: number };
|
_count?: { variants: number };
|
||||||
} | null;
|
} | null;
|
||||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||||
|
mergedOriginGoods?: Array<{
|
||||||
|
originGood: {
|
||||||
|
id: bigint;
|
||||||
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: unknown;
|
||||||
|
detail?: { syncedAt: Date } | Record<string, unknown> | null;
|
||||||
|
variants?: Array<{ [key: string]: unknown }>;
|
||||||
|
_count?: { variants: number };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergedOriginGoodSummary {
|
||||||
|
id: string;
|
||||||
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
|
isCustom: boolean;
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: string | null;
|
||||||
|
hasDetail: boolean;
|
||||||
|
variantCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GoodDto {
|
export class GoodDto {
|
||||||
@@ -80,6 +106,9 @@ export class GoodDto {
|
|||||||
@ApiProperty({ required: false, type: Array })
|
@ApiProperty({ required: false, type: Array })
|
||||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: Array })
|
||||||
|
mergedOriginGoods!: MergedOriginGoodSummary[];
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
position?: { id: string; indexVal: number } | null;
|
position?: { id: string; indexVal: number } | null;
|
||||||
|
|
||||||
@@ -87,6 +116,8 @@ export class GoodDto {
|
|||||||
originGood?: {
|
originGood?: {
|
||||||
id: string;
|
id: string;
|
||||||
sdsGoodId: string;
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
|
isCustom: boolean;
|
||||||
goodName: string | null;
|
goodName: string | null;
|
||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: string | null;
|
goodPrice: string | null;
|
||||||
@@ -144,6 +175,21 @@ export class GoodDto {
|
|||||||
tagFontColor: gt.tag.tagFontColor,
|
tagFontColor: gt.tag.tagFontColor,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
|
mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({
|
||||||
|
id: m.originGood.id.toString(),
|
||||||
|
sdsGoodId: m.originGood.sdsGoodId,
|
||||||
|
source: m.originGood.source,
|
||||||
|
isCustom: m.originGood.source === 'CUSTOM',
|
||||||
|
goodName: m.originGood.goodName,
|
||||||
|
goodImage: m.originGood.goodImage,
|
||||||
|
goodPrice:
|
||||||
|
m.originGood.goodPrice === null || m.originGood.goodPrice === undefined
|
||||||
|
? null
|
||||||
|
: (m.originGood.goodPrice as { toString(): string }).toString(),
|
||||||
|
hasDetail: Boolean(m.originGood.detail),
|
||||||
|
variantCount:
|
||||||
|
m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0,
|
||||||
|
})),
|
||||||
position: rel.position
|
position: rel.position
|
||||||
? {
|
? {
|
||||||
id: rel.position.id.toString(),
|
id: rel.position.id.toString(),
|
||||||
@@ -154,6 +200,8 @@ export class GoodDto {
|
|||||||
? {
|
? {
|
||||||
id: rel.originGood.id.toString(),
|
id: rel.originGood.id.toString(),
|
||||||
sdsGoodId: rel.originGood.sdsGoodId,
|
sdsGoodId: rel.originGood.sdsGoodId,
|
||||||
|
source: rel.originGood.source,
|
||||||
|
isCustom: rel.originGood.source === 'CUSTOM',
|
||||||
goodName: rel.originGood.goodName,
|
goodName: rel.originGood.goodName,
|
||||||
goodImage: rel.originGood.goodImage,
|
goodImage: rel.originGood.goodImage,
|
||||||
goodPrice:
|
goodPrice:
|
||||||
@@ -189,16 +237,39 @@ export class GoodDetailDto extends GoodDto {
|
|||||||
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||||||
const base = GoodDto.from(good, rel);
|
const base = GoodDto.from(good, rel);
|
||||||
const detail = rel.originGood?.detail;
|
const detail = rel.originGood?.detail;
|
||||||
return {
|
const toAnnotated = (
|
||||||
...base,
|
variant: Record<string, unknown>,
|
||||||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
originGoodId: string,
|
||||||
variants: (rel.originGood?.variants ?? []).map((variant) => ({
|
originGoodName: string | null,
|
||||||
|
) => ({
|
||||||
...variant,
|
...variant,
|
||||||
price:
|
price:
|
||||||
variant.price === null || variant.price === undefined
|
variant.price === null || variant.price === undefined
|
||||||
? null
|
? null
|
||||||
: (variant.price as { toString(): string }).toString(),
|
: (variant.price as unknown as { toString(): string }).toString(),
|
||||||
})),
|
originGoodId,
|
||||||
|
originGoodName,
|
||||||
|
});
|
||||||
|
const primaryId = good.originGoodId.toString();
|
||||||
|
const primaryName = rel.originGood?.goodName ?? null;
|
||||||
|
const mergedVariants = [
|
||||||
|
...(rel.originGood?.variants ?? []).map((variant) =>
|
||||||
|
toAnnotated(variant as Record<string, unknown>, primaryId, primaryName),
|
||||||
|
),
|
||||||
|
...(rel.mergedOriginGoods ?? []).flatMap((m) =>
|
||||||
|
(m.originGood.variants ?? []).map((variant) =>
|
||||||
|
toAnnotated(
|
||||||
|
variant,
|
||||||
|
m.originGood.id.toString(),
|
||||||
|
m.originGood.goodName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
||||||
|
variants: mergedVariants,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export class UpdateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId?: number;
|
originGoodId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID 全量覆盖,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { UpdateGoodDto } from './dto/update-good.dto';
|
|||||||
import { QueryGoodDto } from './dto/query-good.dto';
|
import { QueryGoodDto } from './dto/query-good.dto';
|
||||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||||
|
import {
|
||||||
|
CreateCustomGoodDto,
|
||||||
|
UpdateCustomGoodContentDto,
|
||||||
|
} from './dto/custom-good.dto';
|
||||||
|
|
||||||
@ApiTags('goods')
|
@ApiTags('goods')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -54,6 +58,21 @@ export class GoodsController {
|
|||||||
return this.service.batchCreate(dto);
|
return this.service.batchCreate(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('custom')
|
||||||
|
@ApiOperation({ summary: 'Create a fully editable custom product' })
|
||||||
|
createCustom(@Body() dto: CreateCustomGoodDto) {
|
||||||
|
return this.service.createCustom(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/custom-content')
|
||||||
|
@ApiOperation({ summary: 'Update editable content for a custom product' })
|
||||||
|
updateCustomContent(
|
||||||
|
@Param('id', ParseIntPipe) id: string,
|
||||||
|
@Body() dto: UpdateCustomGoodContentDto,
|
||||||
|
) {
|
||||||
|
return this.service.updateCustomContent(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get one good with relations' })
|
@ApiOperation({ summary: 'Get one good with relations' })
|
||||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
|||||||
@@ -119,6 +119,51 @@ describe('GoodsService', () => {
|
|||||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates, edits, and removes a fully editable custom good', async () => {
|
||||||
|
const created = await service.createCustom({
|
||||||
|
goodName: `Goods Test ${stamp} custom`,
|
||||||
|
goodImage: 'https://example.com/custom.png',
|
||||||
|
goodPrice: '29.90',
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
tagIds: [Number(tagId)],
|
||||||
|
detail: {
|
||||||
|
productCode: `CUSTOM-${stamp}`,
|
||||||
|
materialDescription: 'Cotton',
|
||||||
|
sizeChart: { columns: [], rows: [] },
|
||||||
|
packageSpecs: { rows: [] },
|
||||||
|
},
|
||||||
|
variants: [
|
||||||
|
{ sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.originGood?.source).toBe('CUSTOM');
|
||||||
|
expect(created.originGood?.isCustom).toBe(true);
|
||||||
|
expect(created.originGood?.goodPrice).toBe('29.9');
|
||||||
|
expect(created.variants).toHaveLength(1);
|
||||||
|
|
||||||
|
const updated = await service.updateCustomContent(BigInt(created.id), {
|
||||||
|
goodName: `Goods Test ${stamp} custom edited`,
|
||||||
|
goodPrice: '39.90',
|
||||||
|
detail: { materialDescription: 'Organic cotton' },
|
||||||
|
variants: [
|
||||||
|
{ sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(updated.goodName).toContain('custom edited');
|
||||||
|
expect(updated.originGood?.goodPrice).toBe('39.9');
|
||||||
|
expect(updated.originDetail?.materialDescription).toBe('Organic cotton');
|
||||||
|
expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`);
|
||||||
|
expect(updated.variants[0]?.sizeName).toBe('M');
|
||||||
|
|
||||||
|
const customOriginId = BigInt(updated.originGoodId);
|
||||||
|
await service.remove(BigInt(updated.id));
|
||||||
|
await expect(
|
||||||
|
prisma.originGood.findUnique({ where: { id: customOriginId } }),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('filters by countryId, tagId, positionId and keyword', async () => {
|
it('filters by countryId, tagId, positionId and keyword', async () => {
|
||||||
const result = await service.findAll({
|
const result = await service.findAll({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -216,6 +261,135 @@ describe('GoodsService', () => {
|
|||||||
expect(after.total).toBe(before.total);
|
expect(after.total).toBe(before.total);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('merged origin goods', () => {
|
||||||
|
it('creates a good with merged origin goods and reads them back', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} merged`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||||
|
[originGoodIds[2].toString(), originGoodIds[3].toString()].sort(),
|
||||||
|
);
|
||||||
|
const fetched = await service.findOne(BigInt(created.id));
|
||||||
|
expect(fetched.mergedOriginGoods.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mergedOriginGoodIds containing the primary', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
goodName: `Goods Test ${stamp} bad-primary`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[1])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mergedOriginGoodIds that do not exist', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
goodName: `Goods Test ${stamp} bad-missing`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [999999999],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces merged origin goods on update', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} replace`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
const updated = await service.update(BigInt(created.id), {
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])],
|
||||||
|
});
|
||||||
|
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||||
|
[originGoodIds[3].toString(), originGoodIds[4].toString()].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves old primary into merged list when switching primary', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} switch`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
const updated = await service.update(BigInt(created.id), {
|
||||||
|
originGoodId: Number(originGoodIds[2]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])],
|
||||||
|
});
|
||||||
|
expect(updated.originGoodId).toBe(originGoodIds[2].toString());
|
||||||
|
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||||
|
[originGoodIds[1].toString(), originGoodIds[3].toString()].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades merged rows on good removal', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} cascade`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
await service.remove(BigInt(created.id));
|
||||||
|
const rows = await prisma.goodOriginGood.count({
|
||||||
|
where: { goodId: BigInt(created.id) },
|
||||||
|
});
|
||||||
|
expect(rows).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns merged variants with source annotation in detail', async () => {
|
||||||
|
const v1 = await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: originGoodIds[1],
|
||||||
|
sdsVariantId: `mv-pri-${stamp}`,
|
||||||
|
sku: `MV-PRI-${stamp}`,
|
||||||
|
colorName: '黑色',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const v2 = await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: originGoodIds[2],
|
||||||
|
sdsVariantId: `mv-sec-${stamp}`,
|
||||||
|
sku: `MV-SEC-${stamp}`,
|
||||||
|
colorName: '白色',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} variants`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
const detail = await service.findOne(BigInt(created.id));
|
||||||
|
const sources = new Set(
|
||||||
|
detail.variants.map((v) => v['originGoodId'] as string),
|
||||||
|
);
|
||||||
|
expect(sources.has(originGoodIds[1].toString())).toBe(true);
|
||||||
|
expect(sources.has(originGoodIds[2].toString())).toBe(true);
|
||||||
|
expect(detail.variants).toHaveLength(2);
|
||||||
|
expect(detail.mergedOriginGoods.find((m) => m.id === originGoodIds[2].toString())?.variantCount).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await prisma.originGoodVariant.delete({ where: { id: v1.id } });
|
||||||
|
await prisma.originGoodVariant.delete({ where: { id: v2.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('throws NotFoundException for unknown id', async () => {
|
it('throws NotFoundException for unknown id', async () => {
|
||||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
|||||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import {
|
||||||
|
CreateCustomGoodDto,
|
||||||
|
CustomGoodDetailDto,
|
||||||
|
CustomGoodVariantDto,
|
||||||
|
UpdateCustomGoodContentDto,
|
||||||
|
} from './dto/custom-good.dto';
|
||||||
|
|
||||||
const GOOD_INCLUDE = {
|
const GOOD_INCLUDE = {
|
||||||
country: true,
|
country: true,
|
||||||
@@ -26,6 +33,18 @@ const GOOD_INCLUDE = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
goodTags: { include: { tag: true } },
|
goodTags: { include: { tag: true } },
|
||||||
|
mergedOriginGoods: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: {
|
||||||
|
originGood: {
|
||||||
|
include: {
|
||||||
|
detail: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
_count: { select: { variants: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} satisfies Prisma.GoodInclude;
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -68,6 +87,7 @@ export class GoodsService {
|
|||||||
position: g.position,
|
position: g.position,
|
||||||
originGood: g.originGood,
|
originGood: g.originGood,
|
||||||
goodTags: g.goodTags,
|
goodTags: g.goodTags,
|
||||||
|
mergedOriginGoods: g.mergedOriginGoods,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
@@ -88,11 +108,17 @@ export class GoodsService {
|
|||||||
position: good.position,
|
position: good.position,
|
||||||
originGood: good.originGood,
|
originGood: good.originGood,
|
||||||
goodTags: good.goodTags,
|
goodTags: good.goodTags,
|
||||||
|
mergedOriginGoods: good.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||||
await this.ensureReferences(dto);
|
await this.ensureReferences(dto);
|
||||||
|
const mergedIds = this.dedupeMergedIds(
|
||||||
|
BigInt(dto.originGoodId),
|
||||||
|
dto.mergedOriginGoodIds,
|
||||||
|
);
|
||||||
|
await this.ensureMergedOriginGoods(mergedIds);
|
||||||
const result = await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.good.create({
|
const created = await tx.good.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -113,6 +139,14 @@ export class GoodsService {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (mergedIds.length > 0) {
|
||||||
|
await tx.goodOriginGood.createMany({
|
||||||
|
data: mergedIds.map((originGoodId) => ({
|
||||||
|
goodId: created.id,
|
||||||
|
originGoodId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await tx.good.findUniqueOrThrow({
|
const result = await tx.good.findUniqueOrThrow({
|
||||||
where: { id: created.id },
|
where: { id: created.id },
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
@@ -124,16 +158,133 @@ export class GoodsService {
|
|||||||
position: result.position,
|
position: result.position,
|
||||||
originGood: result.originGood,
|
originGood: result.originGood,
|
||||||
goodTags: result.goodTags,
|
goodTags: result.goodTags,
|
||||||
|
mergedOriginGoods: result.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
|
if (
|
||||||
|
result.originGood?.source === 'SDS' &&
|
||||||
|
result.originGood.sdsGoodId &&
|
||||||
|
!result.originGood.hasDetail
|
||||||
|
) {
|
||||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createCustom(dto: CreateCustomGoodDto): Promise<GoodDetailDto> {
|
||||||
|
await this.ensureCountry(dto.countryId);
|
||||||
|
await this.ensureCategory(dto.categoryId);
|
||||||
|
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
|
||||||
|
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
|
||||||
|
|
||||||
|
const goodId = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const originGood = await tx.originGood.create({
|
||||||
|
data: {
|
||||||
|
source: 'CUSTOM',
|
||||||
|
sdsGoodId: `custom-${randomUUID()}`,
|
||||||
|
goodName: dto.goodName,
|
||||||
|
goodImage: dto.goodImage ?? null,
|
||||||
|
goodPrice: this.decimal(dto.goodPrice),
|
||||||
|
detail: {
|
||||||
|
create: this.customDetailData(
|
||||||
|
dto.detail ?? {},
|
||||||
|
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (dto.variants?.length) {
|
||||||
|
await this.replaceCustomVariants(tx, originGood.id, dto.variants);
|
||||||
|
}
|
||||||
|
const good = await tx.good.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: originGood.id,
|
||||||
|
countryId: BigInt(dto.countryId),
|
||||||
|
categoryId: BigInt(dto.categoryId),
|
||||||
|
positionId:
|
||||||
|
dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||||
|
goodName: dto.goodName,
|
||||||
|
goodImage: dto.goodImage ?? null,
|
||||||
|
goodPriority: dto.goodPriority ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (dto.tagIds?.length) {
|
||||||
|
await tx.goodTag.createMany({
|
||||||
|
data: dto.tagIds.map((tagId) => ({
|
||||||
|
goodId: good.id,
|
||||||
|
tagId: BigInt(tagId),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return good.id;
|
||||||
|
});
|
||||||
|
return this.findOne(goodId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCustomContent(
|
||||||
|
id: bigint,
|
||||||
|
dto: UpdateCustomGoodContentDto,
|
||||||
|
): Promise<GoodDetailDto> {
|
||||||
|
const existing = await this.prisma.good.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { originGood: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException(`Good ${id} not found`);
|
||||||
|
if (existing.originGood.source !== 'CUSTOM') {
|
||||||
|
throw new BadRequestException('SDS 映射商品的上游信息不可修改');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.originGood.update({
|
||||||
|
where: { id: existing.originGoodId },
|
||||||
|
data: {
|
||||||
|
goodName: dto.goodName,
|
||||||
|
goodImage: dto.goodImage,
|
||||||
|
goodPrice:
|
||||||
|
dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (dto.detail !== undefined) {
|
||||||
|
await tx.originGoodDetail.upsert({
|
||||||
|
where: { originGoodId: existing.originGoodId },
|
||||||
|
create: {
|
||||||
|
originGoodId: existing.originGoodId,
|
||||||
|
...(this.customDetailData(
|
||||||
|
dto.detail,
|
||||||
|
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput),
|
||||||
|
},
|
||||||
|
update: this.customDetailData(dto.detail, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dto.variants !== undefined) {
|
||||||
|
await this.replaceCustomVariants(
|
||||||
|
tx,
|
||||||
|
existing.originGoodId,
|
||||||
|
dto.variants,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const goodData: Prisma.GoodUpdateInput = {};
|
||||||
|
if (dto.goodName !== undefined) goodData.goodName = dto.goodName;
|
||||||
|
if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage;
|
||||||
|
if (Object.keys(goodData).length) {
|
||||||
|
await tx.good.update({ where: { id }, data: goodData });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
|
let mergedIds: bigint[] | undefined;
|
||||||
|
if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) {
|
||||||
|
const current = await this.prisma.good.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: { originGoodId: true },
|
||||||
|
});
|
||||||
|
const primaryId =
|
||||||
|
dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId;
|
||||||
|
mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds);
|
||||||
|
await this.ensureMergedOriginGoods(mergedIds);
|
||||||
|
}
|
||||||
const data: Prisma.GoodUpdateInput = {};
|
const data: Prisma.GoodUpdateInput = {};
|
||||||
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
||||||
if (dto.originGoodId !== undefined) {
|
if (dto.originGoodId !== undefined) {
|
||||||
@@ -174,6 +325,17 @@ export class GoodsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (mergedIds !== undefined) {
|
||||||
|
await tx.goodOriginGood.deleteMany({ where: { goodId: id } });
|
||||||
|
if (mergedIds.length > 0) {
|
||||||
|
await tx.goodOriginGood.createMany({
|
||||||
|
data: mergedIds.map((originGoodId) => ({
|
||||||
|
goodId: id,
|
||||||
|
originGoodId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const updated = await tx.good.update({
|
const updated = await tx.good.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -186,17 +348,36 @@ export class GoodsService {
|
|||||||
position: updated.position,
|
position: updated.position,
|
||||||
originGood: updated.originGood,
|
originGood: updated.originGood,
|
||||||
goodTags: updated.goodTags,
|
goodTags: updated.goodTags,
|
||||||
|
mergedOriginGoods: updated.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
|
if (
|
||||||
|
result.originGood?.source === 'SDS' &&
|
||||||
|
result.originGood.sdsGoodId &&
|
||||||
|
!result.originGood.hasDetail
|
||||||
|
) {
|
||||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: bigint): Promise<{ id: string }> {
|
async remove(id: bigint): Promise<{ id: string }> {
|
||||||
await this.findOne(id);
|
const good = await this.prisma.good.findUnique({
|
||||||
await this.prisma.good.delete({ where: { id } });
|
where: { id },
|
||||||
|
include: { originGood: true },
|
||||||
|
});
|
||||||
|
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.good.delete({ where: { id } });
|
||||||
|
if (good.originGood.source === 'CUSTOM') {
|
||||||
|
const remaining = await tx.good.count({
|
||||||
|
where: { originGoodId: good.originGoodId },
|
||||||
|
});
|
||||||
|
if (remaining === 0) {
|
||||||
|
await tx.originGood.delete({ where: { id: good.originGoodId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
return { id: id.toString() };
|
return { id: id.toString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +439,24 @@ export class GoodsService {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds);
|
||||||
|
if (itemMerged.length > 0) {
|
||||||
|
const existRows = await tx.originGood.findMany({
|
||||||
|
where: { id: { in: itemMerged } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existRows.length !== itemMerged.length) {
|
||||||
|
const found = new Set(existRows.map((r) => r.id.toString()));
|
||||||
|
const missing = itemMerged.find((mid) => !found.has(mid.toString()));
|
||||||
|
throw new BadRequestException(`Origin good ${missing} not found`);
|
||||||
|
}
|
||||||
|
await tx.goodOriginGood.createMany({
|
||||||
|
data: itemMerged.map((originGoodId) => ({
|
||||||
|
goodId: row.id,
|
||||||
|
originGoodId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await tx.good.findUniqueOrThrow({
|
const result = await tx.good.findUniqueOrThrow({
|
||||||
where: { id: row.id },
|
where: { id: row.id },
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
@@ -269,13 +468,18 @@ export class GoodsService {
|
|||||||
position: result.position,
|
position: result.position,
|
||||||
originGood: result.originGood,
|
originGood: result.originGood,
|
||||||
goodTags: result.goodTags,
|
goodTags: result.goodTags,
|
||||||
|
mergedOriginGoods: result.mergedOriginGoods,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
for (const goodId of new Set(
|
for (const goodId of new Set(
|
||||||
result
|
result
|
||||||
.filter((item) => !item.originGood?.hasDetail)
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.originGood?.source === 'SDS' &&
|
||||||
|
!item.originGood.hasDetail,
|
||||||
|
)
|
||||||
.map((item) => item.originGood?.sdsGoodId)
|
.map((item) => item.originGood?.sdsGoodId)
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
)) {
|
)) {
|
||||||
@@ -312,6 +516,31 @@ export class GoodsService {
|
|||||||
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dedupe merged ids and reject any that equals the primary source. */
|
||||||
|
private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] {
|
||||||
|
if (!ids || ids.length === 0) return [];
|
||||||
|
const unique = [...new Set(ids.map((id) => BigInt(id)))];
|
||||||
|
if (unique.includes(primaryId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'mergedOriginGoodIds 不能包含主源 originGoodId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureMergedOriginGoods(ids: bigint[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const rows = await this.prisma.originGood.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (rows.length !== ids.length) {
|
||||||
|
const found = new Set(rows.map((r) => r.id.toString()));
|
||||||
|
const missing = ids.find((id) => !found.has(id.toString()));
|
||||||
|
throw new BadRequestException(`Origin good ${missing} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureCountry(id: number) {
|
private async ensureCountry(id: number) {
|
||||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||||
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||||
@@ -341,4 +570,93 @@ export class GoodsService {
|
|||||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensurePosition(id: number) {
|
||||||
|
const position = await this.prisma.position.findUnique({
|
||||||
|
where: { id: BigInt(id) },
|
||||||
|
});
|
||||||
|
if (!position) throw new BadRequestException(`Position ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decimal(value: string | null | undefined): Prisma.Decimal | null {
|
||||||
|
return value === undefined || value === null || value === ''
|
||||||
|
? null
|
||||||
|
: new Prisma.Decimal(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private customDetailData(
|
||||||
|
detail: CustomGoodDetailDto,
|
||||||
|
preserveMissing = false,
|
||||||
|
): Prisma.OriginGoodDetailUncheckedUpdateInput {
|
||||||
|
const nullable = <T>(value: T | null | undefined): T | null | undefined =>
|
||||||
|
preserveMissing && value === undefined ? undefined : value ?? null;
|
||||||
|
const decimal = (value: string | null | undefined) =>
|
||||||
|
preserveMissing && value === undefined ? undefined : this.decimal(value);
|
||||||
|
const json = (
|
||||||
|
value: Record<string, unknown> | null | undefined,
|
||||||
|
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined =>
|
||||||
|
preserveMissing && value === undefined
|
||||||
|
? undefined
|
||||||
|
: value === null || value === undefined
|
||||||
|
? Prisma.DbNull
|
||||||
|
: (value as Prisma.InputJsonValue);
|
||||||
|
return {
|
||||||
|
productCode: nullable(detail.productCode),
|
||||||
|
englishName: nullable(detail.englishName),
|
||||||
|
blankDesignUrl: nullable(detail.blankDesignUrl),
|
||||||
|
detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl),
|
||||||
|
textureName: nullable(detail.textureName),
|
||||||
|
productionCycleHours: nullable(detail.productionCycleHours),
|
||||||
|
minWeightG: decimal(detail.minWeightG),
|
||||||
|
reminder: nullable(detail.reminder),
|
||||||
|
productionProcess: nullable(detail.productionProcess),
|
||||||
|
materialDescription: nullable(detail.materialDescription),
|
||||||
|
productPerformance: nullable(detail.productPerformance),
|
||||||
|
applicableScenarios: nullable(detail.applicableScenarios),
|
||||||
|
washingInstructions: nullable(detail.washingInstructions),
|
||||||
|
specialDescription: nullable(detail.specialDescription),
|
||||||
|
designExplanation: nullable(detail.designExplanation),
|
||||||
|
designArea: nullable(detail.designArea),
|
||||||
|
pictureRequest: nullable(detail.pictureRequest),
|
||||||
|
sizeChart: json(detail.sizeChart),
|
||||||
|
packageSpecs: json(detail.packageSpecs),
|
||||||
|
options: json(detail.options),
|
||||||
|
media: json(detail.media),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceCustomVariants(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
originGoodId: bigint,
|
||||||
|
variants: CustomGoodVariantDto[],
|
||||||
|
): Promise<void> {
|
||||||
|
await tx.originGoodVariant.deleteMany({ where: { originGoodId } });
|
||||||
|
for (const variant of variants) {
|
||||||
|
await tx.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId,
|
||||||
|
sdsVariantId: `custom-${randomUUID()}`,
|
||||||
|
sku: variant.sku,
|
||||||
|
sizeId: variant.sizeId ?? null,
|
||||||
|
sizeName: variant.sizeName ?? null,
|
||||||
|
colorId: variant.colorId ?? null,
|
||||||
|
colorName: variant.colorName ?? null,
|
||||||
|
colorHex: variant.colorHex ?? null,
|
||||||
|
imageUrl: variant.imageUrl ?? null,
|
||||||
|
price: this.decimal(variant.price),
|
||||||
|
originalPrice: this.decimal(variant.originalPrice),
|
||||||
|
weightG: this.decimal(variant.weightG),
|
||||||
|
boxLengthCm: this.decimal(variant.boxLengthCm),
|
||||||
|
boxWidthCm: this.decimal(variant.boxWidthCm),
|
||||||
|
boxHeightCm: this.decimal(variant.boxHeightCm),
|
||||||
|
enabled: variant.enabled ?? true,
|
||||||
|
sortOrder: variant.sortOrder ?? 0,
|
||||||
|
designData:
|
||||||
|
variant.designData === null || variant.designData === undefined
|
||||||
|
? Prisma.DbNull
|
||||||
|
: (variant.designData as Prisma.InputJsonValue),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-8
@@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import * as cookieParserModule from 'cookie-parser';
|
||||||
import { json } from 'express';
|
import { json } from 'express';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
@@ -28,15 +30,39 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// CORS
|
// Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...)
|
||||||
app.enableCors({
|
// Static assets (/uploads, /assets) are embedded cross-origin by other
|
||||||
origin: true,
|
// sites, so CORP must allow cross-origin reads.
|
||||||
credentials: true,
|
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
|
||||||
});
|
|
||||||
|
|
||||||
// Serve uploaded files
|
// Parse auth cookies (HttpOnly access/refresh tokens). Resolve both the
|
||||||
|
// namespace and its `default` interop shape so it works regardless of
|
||||||
|
// the compiled module interop mode.
|
||||||
|
const cookieParser = (
|
||||||
|
cookieParserModule as unknown as {
|
||||||
|
default?: typeof cookieParserModule;
|
||||||
|
}
|
||||||
|
).default ?? cookieParserModule;
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// CORS: only origins listed in CORS_ORIGINS (comma-separated) are
|
||||||
|
// allowed. Credentials are enabled because the session lives in
|
||||||
|
// HttpOnly cookies. "*" disables the allowlist and reflects any origin
|
||||||
|
// (reflected origins are required when credentials are enabled).
|
||||||
|
const corsOrigins = (process.env.CORS_ORIGINS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const origin = corsOrigins.includes('*') ? true : corsOrigins;
|
||||||
|
app.enableCors(corsOrigins.length > 0 ? { origin, credentials: true } : undefined);
|
||||||
|
|
||||||
|
// Serve uploaded files. nosniff prevents browsers from sniffing a
|
||||||
|
// non-image content type out of an uploaded file.
|
||||||
app.useStaticAssets(join(process.cwd(), 'uploads'), {
|
app.useStaticAssets(join(process.cwd(), 'uploads'), {
|
||||||
prefix: '/uploads/',
|
prefix: '/uploads/',
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
app.useStaticAssets(join(process.cwd(), 'public'), {
|
app.useStaticAssets(join(process.cwd(), 'public'), {
|
||||||
prefix: '/assets/',
|
prefix: '/assets/',
|
||||||
@@ -55,7 +81,9 @@ async function bootstrap() {
|
|||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
|
||||||
// Swagger
|
// Swagger is only exposed outside production to avoid leaking the
|
||||||
|
// full admin API surface.
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('InkReach Product Center API')
|
.setTitle('InkReach Product Center API')
|
||||||
.setDescription('Backend API for InkReach Product Center')
|
.setDescription('Backend API for InkReach Product Center')
|
||||||
@@ -65,11 +93,11 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
}
|
||||||
|
|
||||||
const port = process.env.PORT ?? 3001;
|
const port = process.env.PORT ?? 3001;
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
console.log(`🚀 Application is running on: http://0.0.0.0:${port}`);
|
console.log(`🚀 Application is running on: http://0.0.0.0:${port}`);
|
||||||
console.log(`📚 Swagger documentation: http://0.0.0.0:${port}/api/docs`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make JSON.stringify aware of BigInt so outgoing responses containing
|
// Make JSON.stringify aware of BigInt so outgoing responses containing
|
||||||
|
|||||||
@@ -77,4 +77,69 @@ describe('OriginGoodsService', () => {
|
|||||||
expect(result.total).toBe(0);
|
expect(result.total).toBe(0);
|
||||||
expect(result.items.length).toBe(0);
|
expect(result.items.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getTree merged references', () => {
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
function findOgNode(
|
||||||
|
treeResponse: { tree: any[] },
|
||||||
|
ogId: string,
|
||||||
|
): { configuredCount: number; configuredCountries: string[] } {
|
||||||
|
let found: { configuredCount: number; configuredCountries: string[] } | null = null;
|
||||||
|
const walk = (nodes: any[]) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const hit = (n.originGoods ?? []).find((o: any) => o.id === ogId);
|
||||||
|
if (hit) {
|
||||||
|
found = hit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n.children?.length) walk(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(treeResponse.tree);
|
||||||
|
if (!found) throw new Error(`og node ${ogId} not found in tree`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('counts secondary references as configured', async () => {
|
||||||
|
const sdsCat = `tree-cat-${stamp}`;
|
||||||
|
await prisma.originGood.createMany({
|
||||||
|
data: [
|
||||||
|
{ sdsGoodId: `tree-a-${stamp}`, goodName: `Tree A ${stamp}`, sdsCategoryId: sdsCat },
|
||||||
|
{ sdsGoodId: `tree-b-${stamp}`, goodName: `Tree B ${stamp}`, sdsCategoryId: sdsCat },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
createdSds.push(`tree-a-${stamp}`, `tree-b-${stamp}`);
|
||||||
|
const originA = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-a-${stamp}` } });
|
||||||
|
const originB = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-b-${stamp}` } });
|
||||||
|
|
||||||
|
const cat = await prisma.category.create({
|
||||||
|
data: { categoryName: `Tree Cat ${stamp}`, sdsCategoryId: sdsCat },
|
||||||
|
});
|
||||||
|
const country = await prisma.country.create({
|
||||||
|
data: { countryName: `Tree Country ${stamp}` },
|
||||||
|
});
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `Tree Good ${stamp}`,
|
||||||
|
originGoodId: originA.id,
|
||||||
|
countryId: country.id,
|
||||||
|
categoryId: cat.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.goodOriginGood.create({
|
||||||
|
data: { goodId: good.id, originGoodId: originB.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tree = await service.getTree();
|
||||||
|
const nodeB = findOgNode(tree, originB.id.toString());
|
||||||
|
expect(nodeB.configuredCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(nodeB.configuredCountries).toContain(`Tree Country ${stamp}`);
|
||||||
|
} finally {
|
||||||
|
await prisma.good.delete({ where: { id: good.id } }).catch(() => undefined);
|
||||||
|
await prisma.country.delete({ where: { id: country.id } }).catch(() => undefined);
|
||||||
|
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,9 +67,12 @@ export class OriginGoodsService {
|
|||||||
|
|
||||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||||
const { page, pageSize, keyword } = query;
|
const { page, pageSize, keyword } = query;
|
||||||
const where: Prisma.OriginGoodWhereInput = keyword
|
const where: Prisma.OriginGoodWhereInput = {
|
||||||
? { goodName: { contains: keyword, mode: 'insensitive' } }
|
source: 'SDS',
|
||||||
: {};
|
...(keyword
|
||||||
|
? { goodName: { contains: keyword, mode: 'insensitive' as const } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
const [total, rows] = await this.prisma.$transaction([
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
this.prisma.originGood.count({ where }),
|
this.prisma.originGood.count({ where }),
|
||||||
@@ -111,7 +114,7 @@ export class OriginGoodsService {
|
|||||||
* under a synthetic "未分类" root node.
|
* under a synthetic "未分类" root node.
|
||||||
*/
|
*/
|
||||||
async getTree(): Promise<OriginGoodsTreeResponse> {
|
async getTree(): Promise<OriginGoodsTreeResponse> {
|
||||||
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
|
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.prisma.category.findMany({
|
this.prisma.category.findMany({
|
||||||
where: { sdsCategoryId: { not: null } },
|
where: { sdsCategoryId: { not: null } },
|
||||||
@@ -124,7 +127,7 @@ export class OriginGoodsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.originGood.findMany({
|
this.prisma.originGood.findMany({
|
||||||
where: { delisted: false },
|
where: { delisted: false, source: 'SDS' },
|
||||||
orderBy: { goodName: 'asc' },
|
orderBy: { goodName: 'asc' },
|
||||||
include: { detail: true, _count: { select: { variants: true } } },
|
include: { detail: true, _count: { select: { variants: true } } },
|
||||||
}),
|
}),
|
||||||
@@ -141,7 +144,12 @@ export class OriginGoodsService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.goodTag.findMany({
|
this.prisma.goodTag.findMany({
|
||||||
select: {
|
select: {
|
||||||
good: { select: { originGoodId: true } },
|
good: {
|
||||||
|
select: {
|
||||||
|
originGoodId: true,
|
||||||
|
mergedOriginGoods: { select: { originGoodId: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
tag: {
|
tag: {
|
||||||
select: {
|
select: {
|
||||||
tagName: true,
|
tagName: true,
|
||||||
@@ -154,26 +162,57 @@ export class OriginGoodsService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// Secondary-source references (good_origin_goods)
|
||||||
|
this.prisma.goodOriginGood.groupBy({
|
||||||
|
by: ['originGoodId'],
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.goodOriginGood.findMany({
|
||||||
|
select: {
|
||||||
|
originGoodId: true,
|
||||||
|
good: { select: { country: { select: { countryName: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const countMap = new Map<string, number>();
|
const countMap = new Map<string, number>();
|
||||||
configCounts.forEach((c) =>
|
configCounts.forEach((c) =>
|
||||||
countMap.set(c.originGoodId.toString(), c._count._all),
|
countMap.set(c.originGoodId.toString(), c._count._all),
|
||||||
);
|
);
|
||||||
|
// Secondary (merged) references count towards configured status too.
|
||||||
const countryMap = new Map<string, string[]>();
|
mergedCounts.forEach((c) => {
|
||||||
goodsWithCountries.forEach((g) => {
|
const key = c.originGoodId.toString();
|
||||||
const key = g.originGoodId.toString();
|
countMap.set(key, (countMap.get(key) ?? 0) + c._count._all);
|
||||||
const name = g.country?.countryName;
|
|
||||||
if (!name) return;
|
|
||||||
const arr = countryMap.get(key);
|
|
||||||
if (arr) arr.push(name);
|
|
||||||
else countryMap.set(key, [name]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const countryMap = new Map<string, string[]>();
|
||||||
|
const addCountry = (key: string, name?: string | null) => {
|
||||||
|
if (!name) return;
|
||||||
|
const arr = countryMap.get(key);
|
||||||
|
if (!arr?.includes(name)) {
|
||||||
|
countryMap.set(key, [...(arr ?? []), name]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
goodsWithCountries.forEach((g) =>
|
||||||
|
addCountry(g.originGoodId.toString(), g.country?.countryName),
|
||||||
|
);
|
||||||
|
mergedWithCountries.forEach((m) =>
|
||||||
|
addCountry(m.originGoodId.toString(), m.good.country?.countryName),
|
||||||
|
);
|
||||||
|
|
||||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
||||||
|
const addTag = (
|
||||||
|
key: string,
|
||||||
|
tagInfo: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number },
|
||||||
|
) => {
|
||||||
|
const arr = tagMap.get(key);
|
||||||
|
if (arr) {
|
||||||
|
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||||
|
} else {
|
||||||
|
tagMap.set(key, [tagInfo]);
|
||||||
|
}
|
||||||
|
};
|
||||||
goodsWithTags.forEach((gt) => {
|
goodsWithTags.forEach((gt) => {
|
||||||
const key = gt.good.originGoodId.toString();
|
|
||||||
const tagInfo = {
|
const tagInfo = {
|
||||||
tagName: gt.tag.tagName,
|
tagName: gt.tag.tagName,
|
||||||
tagColor: gt.tag.tagColor,
|
tagColor: gt.tag.tagColor,
|
||||||
@@ -182,11 +221,10 @@ export class OriginGoodsService {
|
|||||||
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
||||||
sortOrder: gt.tag.sortOrder,
|
sortOrder: gt.tag.sortOrder,
|
||||||
};
|
};
|
||||||
const arr = tagMap.get(key);
|
addTag(gt.good.originGoodId.toString(), tagInfo);
|
||||||
if (arr) {
|
// A good's tags also mark its secondary origin goods as configured.
|
||||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
for (const m of gt.good.mergedOriginGoods) {
|
||||||
} else {
|
addTag(m.originGoodId.toString(), tagInfo);
|
||||||
tagMap.set(key, [tagInfo]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export class PublicGoodDetailDto extends PublicGoodDto {
|
|||||||
@ApiProperty({ nullable: true, type: Object })
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
media!: Record<string, unknown> | null;
|
media!: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Array, description: 'Variant images grouped by color' })
|
||||||
|
mediaByColor!: Array<{
|
||||||
|
colorId: string | null;
|
||||||
|
colorName: string | null;
|
||||||
|
colorHex: string | null;
|
||||||
|
images: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true, type: Object })
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
options!: Record<string, unknown> | null;
|
options!: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import {
|
||||||
|
PublicQueryGoodDto,
|
||||||
|
PublicTagFilterDto,
|
||||||
|
} from './public-query-good.dto';
|
||||||
|
|
||||||
|
describe('PublicQueryGoodDto', () => {
|
||||||
|
it('parses tags from a JSON query parameter into nested DTOs', async () => {
|
||||||
|
const dto = plainToInstance(PublicQueryGoodDto, {
|
||||||
|
tags: JSON.stringify([
|
||||||
|
{ tagGroupId: '1', tagIds: ['11', '12'] },
|
||||||
|
{ tagGroupId: '2', tagIds: ['25'] },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dto.tags).toHaveLength(2);
|
||||||
|
expect(dto.tags?.[0]).toBeInstanceOf(PublicTagFilterDto);
|
||||||
|
expect(dto.tags?.[0]).toEqual({
|
||||||
|
tagGroupId: '1',
|
||||||
|
tagIds: ['11', '12'],
|
||||||
|
});
|
||||||
|
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed group and tag ids', async () => {
|
||||||
|
const dto = plainToInstance(PublicQueryGoodDto, {
|
||||||
|
tags: JSON.stringify([
|
||||||
|
{ tagGroupId: 'craft', tagIds: ['11', 'bad'] },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await validate(dto)).not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { plainToInstance, Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
IsNumberString,
|
IsNumberString,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
ValidateNested,
|
||||||
|
ArrayNotEmpty,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -21,6 +22,32 @@ const stringList = ({ value }: { value: unknown }): string[] | undefined => {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tagFilters = ({ value }: { value: unknown }): unknown => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
try {
|
||||||
|
return values.flatMap((item) => {
|
||||||
|
if (typeof item !== 'string') return [item];
|
||||||
|
const parsed = JSON.parse(item) as unknown;
|
||||||
|
return Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
}).map((item) => plainToInstance(PublicTagFilterDto, item));
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PublicTagFilterDto {
|
||||||
|
@ApiProperty({ example: '1' })
|
||||||
|
@IsNumberString()
|
||||||
|
tagGroupId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String], example: ['11', '12', '13'] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsNumberString({}, { each: true })
|
||||||
|
tagIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class PublicQueryGoodDto {
|
export class PublicQueryGoodDto {
|
||||||
@ApiProperty({ required: false, default: 1 })
|
@ApiProperty({ required: false, default: 1 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -47,21 +74,12 @@ export class PublicQueryGoodDto {
|
|||||||
@IsNumberString()
|
@IsNumberString()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiHideProperty()
|
||||||
required: false,
|
|
||||||
type: [String],
|
|
||||||
description:
|
|
||||||
'标签筛选项,格式为 tagGroupId:tagId;支持重复参数或逗号分隔。同组 OR,跨组 AND',
|
|
||||||
example: ['1:11', '1:12', '2:25'],
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(stringList)
|
@Transform(tagFilters)
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Matches(/^\d+:\d+$/, {
|
@ValidateNested({ each: true })
|
||||||
each: true,
|
tags?: PublicTagFilterDto[];
|
||||||
message: 'tagIds 每个元素必须为 tagGroupId:tagId',
|
|
||||||
})
|
|
||||||
tagIds?: string[];
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -4,18 +4,28 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiExtraModels,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
getSchemaPath,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
import {
|
import {
|
||||||
PublicCountryQueryDto,
|
PublicCountryQueryDto,
|
||||||
PublicHomeGoodsQueryDto,
|
PublicHomeGoodsQueryDto,
|
||||||
PublicQueryGoodDto,
|
PublicQueryGoodDto,
|
||||||
|
PublicTagFilterDto,
|
||||||
} from './dto/public-query-good.dto';
|
} from './dto/public-query-good.dto';
|
||||||
import { PublicTagDto } from './dto/public-tag.dto';
|
import { PublicTagDto } from './dto/public-tag.dto';
|
||||||
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
||||||
import { PublicGoodDto } from './dto/public-good.dto';
|
import { PublicGoodDto } from './dto/public-good.dto';
|
||||||
|
|
||||||
@ApiTags('public')
|
@ApiTags('public')
|
||||||
|
@ApiExtraModels(PublicTagFilterDto)
|
||||||
@Controller('public')
|
@Controller('public')
|
||||||
export class PublicController {
|
export class PublicController {
|
||||||
constructor(private readonly service: PublicService) {}
|
constructor(private readonly service: PublicService) {}
|
||||||
@@ -47,6 +57,24 @@ export class PublicController {
|
|||||||
|
|
||||||
@Get('goods')
|
@Get('goods')
|
||||||
@ApiOperation({ summary: '分页获取商品' })
|
@ApiOperation({ summary: '分页获取商品' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'tags',
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
'标签筛选分组。参数值为 JSON 数组;同组 tagIds 按 OR 匹配,不同标签组按 AND 匹配',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: getSchemaPath(PublicTagFilterDto) },
|
||||||
|
},
|
||||||
|
example: [
|
||||||
|
{ tagGroupId: '1', tagIds: ['11', '12'] },
|
||||||
|
{ tagGroupId: '2', tagIds: ['25'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
getGoods(@Query() query: PublicQueryGoodDto) {
|
getGoods(@Query() query: PublicQueryGoodDto) {
|
||||||
return this.service.getGoods(query);
|
return this.service.getGoods(query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,9 +247,12 @@ describe('PublicService', () => {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
countryId: countryId.toString(),
|
countryId: countryId.toString(),
|
||||||
keyword: `Pub `,
|
keyword: `Pub `,
|
||||||
tagIds: filterTagIds
|
tags: [
|
||||||
.slice(0, 2)
|
{
|
||||||
.map((tagId) => `${filterGroupIds[0]}:${tagId}`),
|
tagGroupId: filterGroupIds[0].toString(),
|
||||||
|
tagIds: filterTagIds.slice(0, 2).map(String),
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
|
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
|
||||||
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
|
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
|
||||||
@@ -260,10 +263,16 @@ describe('PublicService', () => {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
countryId: countryId.toString(),
|
countryId: countryId.toString(),
|
||||||
keyword: `Pub `,
|
keyword: `Pub `,
|
||||||
tagIds: filterTagIds.map(
|
tags: [
|
||||||
(tagId, index) =>
|
{
|
||||||
`${index < 2 ? filterGroupIds[0] : filterGroupIds[1]}:${tagId}`,
|
tagGroupId: filterGroupIds[0].toString(),
|
||||||
),
|
tagIds: filterTagIds.slice(0, 2).map(String),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tagGroupId: filterGroupIds[1].toString(),
|
||||||
|
tagIds: [filterTagIds[2].toString()],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`);
|
expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`);
|
||||||
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
|
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
|
||||||
@@ -274,7 +283,12 @@ describe('PublicService', () => {
|
|||||||
service.getGoods({
|
service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
tagIds: [`${filterGroupIds[1]}:${filterTagIds[0]}`],
|
tags: [
|
||||||
|
{
|
||||||
|
tagGroupId: filterGroupIds[1].toString(),
|
||||||
|
tagIds: [filterTagIds[0].toString()],
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
@@ -292,6 +306,36 @@ describe('PublicService', () => {
|
|||||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns custom goods through the same public product contract', async () => {
|
||||||
|
const customPublicId = `custom-public-${stamp}`;
|
||||||
|
const origin = await prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
source: 'CUSTOM',
|
||||||
|
sdsGoodId: customPublicId,
|
||||||
|
goodName: `Pub Custom ${stamp}`,
|
||||||
|
goodPrice: 42,
|
||||||
|
detail: { create: { productCode: `CUSTOM-${stamp}` } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: origin.id,
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
goodName: `Pub Custom ${stamp}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const detail = await service.getGood(customPublicId);
|
||||||
|
expect(detail.goodId).toBe(customPublicId);
|
||||||
|
expect(detail.goodName).toBe(`Pub Custom ${stamp}`);
|
||||||
|
expect(detail.productCode).toBe(`CUSTOM-${stamp}`);
|
||||||
|
} finally {
|
||||||
|
await prisma.good.delete({ where: { id: good.id } });
|
||||||
|
await prisma.originGood.delete({ where: { id: origin.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('getGood returns detail and 404 for unknown id', async () => {
|
it('getGood returns detail and 404 for unknown id', async () => {
|
||||||
const first = await service.getGoods({
|
const first = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -335,4 +379,39 @@ describe('PublicService', () => {
|
|||||||
const sortOrders = groups.map((g) => g.sortOrder);
|
const sortOrders = groups.map((g) => g.sortOrder);
|
||||||
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
|
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('merged secondary origin goods', () => {
|
||||||
|
it('resolves a good by secondary sdsGoodId with merged variants', async () => {
|
||||||
|
const secondary = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` },
|
||||||
|
});
|
||||||
|
const secVariant = await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: secondary.id,
|
||||||
|
sdsVariantId: `pub-var-sec-${stamp}`,
|
||||||
|
sku: `PUB-SEC-${stamp}`,
|
||||||
|
colorId: 'black',
|
||||||
|
colorName: '黑色',
|
||||||
|
imageUrl: 'http://img/black-sec',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Attach as secondary source of the highest-priority fixture good.
|
||||||
|
await prisma.goodOriginGood.create({
|
||||||
|
data: { goodId: goodIds[0], originGoodId: secondary.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await service.getGood(`pub-secondary-${stamp}`);
|
||||||
|
expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源
|
||||||
|
expect(detail.variants.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const black = detail.mediaByColor.find((g) => g.colorName === '黑色');
|
||||||
|
expect(black).toBeTruthy();
|
||||||
|
expect(black!.images).toContain('http://img/black-sec');
|
||||||
|
} finally {
|
||||||
|
await prisma.goodOriginGood.deleteMany({ where: { originGoodId: secondary.id } });
|
||||||
|
await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined);
|
||||||
|
await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import {
|
import {
|
||||||
PublicHomeGoodsQueryDto,
|
PublicHomeGoodsQueryDto,
|
||||||
PublicQueryGoodDto,
|
PublicQueryGoodDto,
|
||||||
|
PublicTagFilterDto,
|
||||||
} from './dto/public-query-good.dto';
|
} from './dto/public-query-good.dto';
|
||||||
import { PublicCategoryNodeDto } from './dto/public-category.dto';
|
import { PublicCategoryNodeDto } from './dto/public-category.dto';
|
||||||
import { PublicCountryDto } from './dto/public-country.dto';
|
import { PublicCountryDto } from './dto/public-country.dto';
|
||||||
@@ -33,6 +34,16 @@ const PUBLIC_GOOD_INCLUDE = {
|
|||||||
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mergedOriginGoods: {
|
||||||
|
orderBy: { createdAt: 'asc' as const },
|
||||||
|
include: {
|
||||||
|
originGood: {
|
||||||
|
include: {
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||||
} satisfies Prisma.GoodInclude;
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
@@ -145,9 +156,7 @@ export class PublicService {
|
|||||||
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
|
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagFilters = await this.buildTagGroupFilters([
|
const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
|
||||||
...new Set(query.tagIds ?? []),
|
|
||||||
]);
|
|
||||||
if (tagFilters.length) where.AND = tagFilters;
|
if (tagFilters.length) where.AND = tagFilters;
|
||||||
|
|
||||||
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
|
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
|
||||||
@@ -199,14 +208,26 @@ export class PublicService {
|
|||||||
|
|
||||||
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
||||||
const good = await this.prisma.good.findFirst({
|
const good = await this.prisma.good.findFirst({
|
||||||
where: { originGood: { sdsGoodId: goodId, delisted: false } },
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ originGood: { sdsGoodId: goodId, delisted: false } },
|
||||||
|
// Merged secondary sources also resolve to the same good.
|
||||||
|
{
|
||||||
|
mergedOriginGoods: {
|
||||||
|
some: { originGood: { sdsGoodId: goodId, delisted: false } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
include: PUBLIC_GOOD_INCLUDE,
|
include: PUBLIC_GOOD_INCLUDE,
|
||||||
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
||||||
});
|
});
|
||||||
if (!good) {
|
if (!good) {
|
||||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||||
}
|
}
|
||||||
return this.toPublicGoodDetail(good);
|
const dto = this.toPublicGoodDetail(good);
|
||||||
|
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
|
||||||
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||||
@@ -271,9 +292,74 @@ export class PublicService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Group distinct variant images by color so the frontend can switch media per color.
|
||||||
|
* Only color-specific photos are included (main / result / detail images);
|
||||||
|
* design-layer素材图 and the product-level blank garment photo are excluded
|
||||||
|
* because they are not per-color gallery photos. */
|
||||||
|
private groupImagesByColor(
|
||||||
|
variants: Array<PublicGoodRow['originGood']['variants'][number]>,
|
||||||
|
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
|
||||||
|
const groups = new Map<string, {
|
||||||
|
colorId: string | null;
|
||||||
|
colorName: string | null;
|
||||||
|
colorHex: string | null;
|
||||||
|
images: string[];
|
||||||
|
}>();
|
||||||
|
for (const variant of variants) {
|
||||||
|
const key = variant.colorId ?? `variant:${variant.sdsVariantId}`;
|
||||||
|
let group = groups.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
colorId: variant.colorId,
|
||||||
|
colorName: variant.colorName,
|
||||||
|
colorHex: variant.colorHex,
|
||||||
|
images: [],
|
||||||
|
};
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
const design = (variant.designData ?? {}) as {
|
||||||
|
detailImgUrls?: Array<{ imageUrl?: unknown }>;
|
||||||
|
prototypeResultGroups?: Array<{ resultImage?: unknown }>;
|
||||||
|
};
|
||||||
|
const urls: unknown[] = [
|
||||||
|
variant.imageUrl,
|
||||||
|
...(design.prototypeResultGroups ?? []).map((item) => item?.resultImage),
|
||||||
|
...(design.detailImgUrls ?? []).map((image) => image?.imageUrl),
|
||||||
|
];
|
||||||
|
for (const url of urls) {
|
||||||
|
const value = typeof url === 'string' ? url.trim() : '';
|
||||||
|
if (value && !group.images.includes(value)) {
|
||||||
|
group.images.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaf categories often have no icon upstream; fall back to the nearest ancestor that has one. */
|
||||||
|
private async resolveCategoryIcon(category: PublicGoodRow['category']): Promise<string | null> {
|
||||||
|
if (category.categoryIcon) return category.categoryIcon;
|
||||||
|
let cursor = category.parentCategoryId;
|
||||||
|
for (let depth = 0; cursor !== null && depth < 10; depth++) {
|
||||||
|
const parent = await this.prisma.category.findUnique({
|
||||||
|
where: { id: cursor },
|
||||||
|
select: { categoryIcon: true, parentCategoryId: true },
|
||||||
|
});
|
||||||
|
if (!parent) break;
|
||||||
|
if (parent.categoryIcon) return parent.categoryIcon;
|
||||||
|
cursor = parent.parentCategoryId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
||||||
const base = this.toPublicGood(good);
|
const base = this.toPublicGood(good);
|
||||||
const detail = good.originGood.detail;
|
const detail = good.originGood.detail;
|
||||||
|
// Merge primary and secondary origin good variants (dedup identical URLs).
|
||||||
|
const allVariants = [
|
||||||
|
...good.originGood.variants,
|
||||||
|
...good.mergedOriginGoods.flatMap((m) => m.originGood.variants),
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
productCode: detail?.productCode ?? null,
|
productCode: detail?.productCode ?? null,
|
||||||
@@ -293,10 +379,11 @@ export class PublicService {
|
|||||||
pictureRequest: detail?.pictureRequest ?? null,
|
pictureRequest: detail?.pictureRequest ?? null,
|
||||||
},
|
},
|
||||||
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
||||||
|
mediaByColor: this.groupImagesByColor(allVariants),
|
||||||
options: (detail?.options as Record<string, unknown> | null) ?? null,
|
options: (detail?.options as Record<string, unknown> | null) ?? null,
|
||||||
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
|
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
|
||||||
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
|
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
|
||||||
variants: good.originGood.variants.map((variant) => ({
|
variants: allVariants.map((variant) => ({
|
||||||
id: variant.sdsVariantId,
|
id: variant.sdsVariantId,
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
sizeId: variant.sizeId,
|
sizeId: variant.sizeId,
|
||||||
@@ -319,16 +406,20 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async buildTagGroupFilters(
|
private async buildTagGroupFilters(
|
||||||
selectedTagValues: string[],
|
selectedGroups: PublicTagFilterDto[],
|
||||||
): Promise<Prisma.GoodWhereInput[]> {
|
): Promise<Prisma.GoodWhereInput[]> {
|
||||||
const selections = selectedTagValues.map((value) => {
|
const selections = selectedGroups.flatMap((group) => {
|
||||||
const [tagGroupId, tagId] = value.split(':');
|
if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
|
||||||
if (!tagGroupId || !tagId || !/^\d+$/.test(tagGroupId) || !/^\d+$/.test(tagId)) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'tagIds 每个元素必须为 tagGroupId:tagId',
|
'tags 每个元素必须包含合法的 tagGroupId 和 tagIds',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { tagGroupId, tagId };
|
return group.tagIds.map((tagId) => {
|
||||||
|
if (!/^\d+$/.test(tagId)) {
|
||||||
|
throw new BadRequestException('tagIds 必须全部为数字字符串');
|
||||||
|
}
|
||||||
|
return { tagGroupId: group.tagGroupId, tagId };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
|
const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
|
||||||
const selected = uniqueTagIds.length
|
const selected = uniqueTagIds.length
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class SyncController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('product-details')
|
@Post('product-details')
|
||||||
@ApiOperation({ summary: 'Manually sync details for all configured products (async)' })
|
@ApiOperation({ summary: 'Manually sync details for all active origin products (async)' })
|
||||||
async syncProductDetails() {
|
async syncProductDetails() {
|
||||||
return this.service.startProductDetailSync();
|
return this.service.startProductDetailSync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,3 +276,67 @@ describe('SyncService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SyncService product detail scopes', () => {
|
||||||
|
const originGoods = [
|
||||||
|
{ id: 1n, sdsGoodId: 'all-1' },
|
||||||
|
{ id: 2n, sdsGoodId: 'all-2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createService() {
|
||||||
|
const prisma = {
|
||||||
|
originGood: { findMany: jest.fn().mockResolvedValue(originGoods) },
|
||||||
|
} as unknown as PrismaService;
|
||||||
|
const sds = {
|
||||||
|
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
|
||||||
|
} as unknown as SdsClientService;
|
||||||
|
const scopedService = new SyncService(prisma, sds);
|
||||||
|
jest
|
||||||
|
.spyOn(scopedService as any, 'persistProductDetail')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
return { scopedService, prisma, sds };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('manual detail sync selects every active origin product', async () => {
|
||||||
|
const { scopedService, prisma, sds } = createService();
|
||||||
|
const result = await scopedService.syncAllProductDetails();
|
||||||
|
|
||||||
|
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { delisted: false, source: 'SDS' } }),
|
||||||
|
);
|
||||||
|
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hourly detail refresh remains limited to configured products', async () => {
|
||||||
|
const { scopedService, prisma } = createService();
|
||||||
|
await scopedService.syncConfiguredProductDetails();
|
||||||
|
|
||||||
|
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { delisted: false, source: 'SDS', goods: { some: {} } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hourly category/product sync separate from the daily detail sync', async () => {
|
||||||
|
const { scopedService } = createService();
|
||||||
|
const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
|
||||||
|
inserted: 0, updated: 0, total: 0, deletedStale: 0,
|
||||||
|
});
|
||||||
|
const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({
|
||||||
|
inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0,
|
||||||
|
});
|
||||||
|
const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({
|
||||||
|
total: 0, synced: 0, failed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await scopedService.hourlyCron();
|
||||||
|
expect(categories).toHaveBeenCalledTimes(1);
|
||||||
|
expect(products).toHaveBeenCalledTimes(1);
|
||||||
|
expect(details).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await scopedService.dailyProductDetailCron();
|
||||||
|
expect(details).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -23,8 +28,6 @@ export interface ProductSyncResult {
|
|||||||
total: number;
|
total: number;
|
||||||
leafCategories: number;
|
leafCategories: number;
|
||||||
delisted: number;
|
delisted: number;
|
||||||
detailsSynced: number;
|
|
||||||
detailFailures: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +113,16 @@ export class SyncService {
|
|||||||
return { message: 'Product sync started' };
|
return { message: 'Product sync started' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh all active SDS product details once per day at 03:30. */
|
||||||
|
@Cron('0 30 3 * * *', { timeZone: 'Asia/Shanghai' })
|
||||||
|
async dailyProductDetailCron(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.syncProductDetails();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Daily product detail sync failed', err as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async startProductDetailSync(): Promise<{ message: string }> {
|
async startProductDetailSync(): Promise<{ message: string }> {
|
||||||
if (this.running.details) {
|
if (this.running.details) {
|
||||||
return { message: 'Product detail sync already in progress' };
|
return { message: 'Product detail sync already in progress' };
|
||||||
@@ -341,11 +354,19 @@ export class SyncService {
|
|||||||
const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size);
|
const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size);
|
||||||
if (runDelist) {
|
if (runDelist) {
|
||||||
const delistedResult = await this.prisma.originGood.updateMany({
|
const delistedResult = await this.prisma.originGood.updateMany({
|
||||||
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false },
|
where: {
|
||||||
|
source: 'SDS',
|
||||||
|
sdsGoodId: { notIn: [...seenSdsGoodIds] },
|
||||||
|
delisted: false,
|
||||||
|
},
|
||||||
data: { delisted: true },
|
data: { delisted: true },
|
||||||
});
|
});
|
||||||
const reactivatedResult = await this.prisma.originGood.updateMany({
|
const reactivatedResult = await this.prisma.originGood.updateMany({
|
||||||
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true },
|
where: {
|
||||||
|
source: 'SDS',
|
||||||
|
sdsGoodId: { in: [...seenSdsGoodIds] },
|
||||||
|
delisted: true,
|
||||||
|
},
|
||||||
data: { delisted: false },
|
data: { delisted: false },
|
||||||
});
|
});
|
||||||
delistedCount = delistedResult.count;
|
delistedCount = delistedResult.count;
|
||||||
@@ -357,17 +378,12 @@ export class SyncService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only hydrate full details for products selected in the website catalog.
|
|
||||||
// This keeps the hourly sync bounded and preserves the existing Good/tag
|
|
||||||
// merchandising model. A failed detail request never erases cached data.
|
|
||||||
const detailResult = await this.syncConfiguredProductDetails();
|
|
||||||
|
|
||||||
await this.prisma.syncLog.update({
|
await this.prisma.syncLog.update({
|
||||||
where: { id: log.id },
|
where: { id: log.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length} detailsSynced=${detailResult.synced} detailFailures=${detailResult.failed}`,
|
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -376,8 +392,6 @@ export class SyncService {
|
|||||||
total,
|
total,
|
||||||
leafCategories: leafRows.length,
|
leafCategories: leafRows.length,
|
||||||
delisted: delistedCount,
|
delisted: delistedCount,
|
||||||
detailsSynced: detailResult.synced,
|
|
||||||
detailFailures: detailResult.failed,
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -402,7 +416,11 @@ export class SyncService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncProductDetails(): Promise<{ synced: number; failed: number }> {
|
async syncProductDetails(): Promise<{
|
||||||
|
total: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
}> {
|
||||||
if (this.running.details) {
|
if (this.running.details) {
|
||||||
throw new Error('Product detail sync already in progress');
|
throw new Error('Product detail sync already in progress');
|
||||||
}
|
}
|
||||||
@@ -411,13 +429,20 @@ export class SyncService {
|
|||||||
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
|
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await this.syncConfiguredProductDetails();
|
const result = await this.syncAllProductDetails(async (progress) => {
|
||||||
|
await this.prisma.syncLog.update({
|
||||||
|
where: { id: log.id },
|
||||||
|
data: {
|
||||||
|
message: `processed=${progress.processed}/${progress.total} synced=${progress.synced} failed=${progress.failed}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
await this.prisma.syncLog.update({
|
await this.prisma.syncLog.update({
|
||||||
where: { id: log.id },
|
where: { id: log.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
message: `synced=${result.synced} failed=${result.failed}`,
|
message: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -440,11 +465,14 @@ export class SyncService {
|
|||||||
}> {
|
}> {
|
||||||
const originGood = await this.prisma.originGood.findUnique({
|
const originGood = await this.prisma.originGood.findUnique({
|
||||||
where: { sdsGoodId: goodId },
|
where: { sdsGoodId: goodId },
|
||||||
select: { id: true },
|
select: { id: true, source: true },
|
||||||
});
|
});
|
||||||
if (!originGood) {
|
if (!originGood) {
|
||||||
throw new NotFoundException(`SDS product ${goodId} not found locally`);
|
throw new NotFoundException(`SDS product ${goodId} not found locally`);
|
||||||
}
|
}
|
||||||
|
if (originGood.source !== 'SDS') {
|
||||||
|
throw new BadRequestException('自定义商品不支持从 SDS 同步详情');
|
||||||
|
}
|
||||||
const upstream = await this.sds.fetchProductDetail(goodId);
|
const upstream = await this.sds.fetchProductDetail(goodId);
|
||||||
const normalized = normalizeProductDetail(upstream);
|
const normalized = normalizeProductDetail(upstream);
|
||||||
await this.persistProductDetail(originGood.id, upstream);
|
await this.persistProductDetail(originGood.id, upstream);
|
||||||
@@ -463,25 +491,72 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||||
const configured = await this.prisma.originGood.findMany({
|
const result = await this.syncMatchingProductDetails({
|
||||||
where: { delisted: false, goods: { some: {} } },
|
delisted: false,
|
||||||
|
source: 'SDS',
|
||||||
|
goods: { some: {} },
|
||||||
|
});
|
||||||
|
return { synced: result.synced, failed: result.failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAllProductDetails(
|
||||||
|
onProgress?: (progress: {
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
}) => Promise<void>,
|
||||||
|
): Promise<{ total: number; synced: number; failed: number }> {
|
||||||
|
return this.syncMatchingProductDetails(
|
||||||
|
{ delisted: false, source: 'SDS' },
|
||||||
|
onProgress,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncMatchingProductDetails(
|
||||||
|
where: Prisma.OriginGoodWhereInput,
|
||||||
|
onProgress?: (progress: {
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
}) => Promise<void>,
|
||||||
|
attempts = 1,
|
||||||
|
): Promise<{ total: number; synced: number; failed: number }> {
|
||||||
|
const originGoods = await this.prisma.originGood.findMany({
|
||||||
|
where,
|
||||||
select: { id: true, sdsGoodId: true },
|
select: { id: true, sdsGoodId: true },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
});
|
});
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const originGood of configured) {
|
let processed = 0;
|
||||||
|
for (const originGood of originGoods) {
|
||||||
|
let lastError: unknown;
|
||||||
|
let succeeded = false;
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
|
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
|
||||||
await this.persistProductDetail(originGood.id, upstream);
|
await this.persistProductDetail(originGood.id, upstream);
|
||||||
synced++;
|
synced++;
|
||||||
|
succeeded = true;
|
||||||
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!succeeded) {
|
||||||
failed++;
|
failed++;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
||||||
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
|
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
|
||||||
}
|
}
|
||||||
|
processed++;
|
||||||
|
if (onProgress && (processed % 10 === 0 || processed === originGoods.length)) {
|
||||||
|
await onProgress({ processed, total: originGoods.length, synced, failed });
|
||||||
}
|
}
|
||||||
return { synced, failed };
|
}
|
||||||
|
return { total: originGoods.length, synced, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
async importProductDetail(upstream: SdsProductDetail): Promise<{
|
async importProductDetail(upstream: SdsProductDetail): Promise<{
|
||||||
@@ -533,6 +608,13 @@ export class SyncService {
|
|||||||
const { variants, ...detail } = normalized;
|
const { variants, ...detail } = normalized;
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull;
|
const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull;
|
||||||
|
// Backfill the origin good's price from upstream min_price when present
|
||||||
|
if (upstream.min_price !== undefined && upstream.min_price !== null) {
|
||||||
|
await tx.originGood.update({
|
||||||
|
where: { id: originGoodId },
|
||||||
|
data: { goodPrice: new Prisma.Decimal(Number(upstream.min_price)) },
|
||||||
|
});
|
||||||
|
}
|
||||||
await tx.originGoodDetail.upsert({
|
await tx.originGoodDetail.upsert({
|
||||||
where: { originGoodId },
|
where: { originGoodId },
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname, join } from 'path';
|
import { extname, join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
|
||||||
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
||||||
|
|
||||||
|
// Explicit safe-image whitelist. SVG is deliberately excluded: it can
|
||||||
|
// carry scripts and is served from the same origin (stored XSS).
|
||||||
|
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i;
|
||||||
|
const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i;
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('upload')
|
@Controller('upload')
|
||||||
export class UploadController {
|
export class UploadController {
|
||||||
@Post('image')
|
@Post('image')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: UPLOAD_DIR,
|
destination: UPLOAD_DIR,
|
||||||
filename: (_req, file, cb) => {
|
filename: (_req, file, cb) => {
|
||||||
const ext = extname(file.originalname) || '.png';
|
const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname))
|
||||||
|
? extname(file.originalname).toLowerCase()
|
||||||
|
: '.png';
|
||||||
cb(null, `${randomUUID()}${ext}`);
|
cb(null, `${randomUUID()}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 5 * 1024 * 1024 },
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
if (!file.mimetype.startsWith('image/')) {
|
if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
|
||||||
return cb(new BadRequestException('仅支持图片文件'), false);
|
return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
|
||||||
}
|
}
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
},
|
},
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
@@ -316,7 +316,15 @@ export function useProductCenter() {
|
|||||||
if (query.countryId) params.countryId = query.countryId;
|
if (query.countryId) params.countryId = query.countryId;
|
||||||
if (query.categoryId) params.categoryId = query.categoryId;
|
if (query.categoryId) params.categoryId = query.categoryId;
|
||||||
if (!options.ignoreTags && query.tagIds.length > 0) {
|
if (!options.ignoreTags && query.tagIds.length > 0) {
|
||||||
params.tagIds = query.tagIds.join(',');
|
const grouped = new Map<string, string[]>();
|
||||||
|
for (const tagId of query.tagIds) {
|
||||||
|
const groupId = tags.value.find((tag) => tag.id === tagId)?.group?.id;
|
||||||
|
if (!groupId) continue;
|
||||||
|
grouped.set(groupId, [...(grouped.get(groupId) ?? []), tagId]);
|
||||||
|
}
|
||||||
|
params.tags = JSON.stringify(
|
||||||
|
[...grouped].map(([tagGroupId, tagIds]) => ({ tagGroupId, tagIds })),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
|
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: false },
|
||||||
|
sourcemap: { server: false, client: false },
|
||||||
|
|
||||||
modules: ['@nuxtjs/seo'],
|
modules: ['@nuxtjs/seo'],
|
||||||
|
|
||||||
|
|||||||
Executable
+8318
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
# InkReach Admin - Vite build served by nginx
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
ARG VITE_API_BASE=/api
|
||||||
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY apps/admin/package.json apps/admin/
|
||||||
|
RUN pnpm install --filter @inkreach/admin --frozen-lockfile
|
||||||
|
|
||||||
|
COPY apps/admin apps/admin
|
||||||
|
# vue-tsc full check is skipped: pre-existing type errors unrelated to the build output
|
||||||
|
RUN pnpm --filter @inkreach/admin exec vite build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY --from=build /app/apps/admin/dist /usr/share/nginx/html/admin
|
||||||
|
COPY deploy/nginx/admin.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# InkReach API - NestJS + Prisma
|
||||||
|
FROM rkli954yqvk81y0vwt.xuanyuan.run/library/node:20-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
RUN pnpm install --filter @inkreach/api --frozen-lockfile
|
||||||
|
|
||||||
|
COPY apps/api apps/api
|
||||||
|
# `pnpm deploy` produces a self-contained dir with real files (not pnpm
|
||||||
|
# symlinks), which survives the Docker COPY into the runtime stage.
|
||||||
|
RUN pnpm --filter @inkreach/api prisma:generate \
|
||||||
|
&& pnpm --filter @inkreach/api build \
|
||||||
|
&& pnpm --filter @inkreach/api deploy --legacy /app/deployed
|
||||||
|
|
||||||
|
FROM rkli954yqvk81y0vwt.xuanyuan.run/library/node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Prisma engines need openssl to detect the libssl version.
|
||||||
|
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /app/deployed .
|
||||||
|
RUN mkdir -p uploads public
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main.js"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: inkreach
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: inkreach
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U inkreach -d inkreach"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
# No ports exposed: only reachable from the compose network
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/api.Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3001
|
||||||
|
DATABASE_URL: postgresql://inkreach:${POSTGRES_PASSWORD}@postgres:5432/inkreach
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
CORS_ORIGINS: "*"
|
||||||
|
volumes:
|
||||||
|
- uploads:/app/uploads
|
||||||
|
|
||||||
|
admin:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/admin.Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE: /api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/admin.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./certbot/www:/var/www/certbot:ro
|
||||||
|
- ./certbot/acme:/etc/nginx/certs:ro
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
uploads:
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name official.inkreach.cc;
|
||||||
|
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
# ACME challenge for cert renewals
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name official.inkreach.cc;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/official.inkreach.cc_ecc/fullchain.cer;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/official.inkreach.cc_ecc/official.inkreach.cc.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin SPA
|
||||||
|
location /admin/ {
|
||||||
|
try_files $uri $uri/ /admin/index.html;
|
||||||
|
}
|
||||||
|
location = /admin {
|
||||||
|
return 301 /admin/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API: strip the /api prefix before proxying to the NestJS container
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:3001/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uploaded files served by the API
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://api:3001/uploads/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static product assets served by the API
|
||||||
|
location /assets/ {
|
||||||
|
proxy_pass http://api:3001/assets/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Website placeholder until the public site is deployed
|
||||||
|
location / {
|
||||||
|
return 302 /admin/;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const f = '/app/dist/src/public/public.service.js';
|
||||||
|
let s = fs.readFileSync(f, 'utf8');
|
||||||
|
const start = s.indexOf(' groupImagesByColor(variants');
|
||||||
|
const end = s.indexOf(' async resolveCategoryIcon(category) {');
|
||||||
|
if (start < 0 || end < 0) {
|
||||||
|
console.error('markers not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const repl = ` groupImagesByColor(variants) {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const variant of variants) {
|
||||||
|
const key = variant.colorId ?? \`variant:\${variant.sdsVariantId}\`;
|
||||||
|
let group = groups.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = { colorId: variant.colorId, colorName: variant.colorName, colorHex: variant.colorHex, images: [] };
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
const design = (variant.designData ?? {});
|
||||||
|
const urls = [
|
||||||
|
variant.imageUrl,
|
||||||
|
...((design.prototypeResultGroups ?? []).map((i) => i?.resultImage)),
|
||||||
|
...((design.detailImgUrls ?? []).map((i) => i?.imageUrl)),
|
||||||
|
];
|
||||||
|
for (const url of urls) {
|
||||||
|
const value = typeof url === 'string' ? url.trim() : '';
|
||||||
|
if (value && !group.images.includes(value)) {
|
||||||
|
group.images.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
s = s.slice(0, start) + repl + s.slice(end);
|
||||||
|
s = s.replace(
|
||||||
|
/mediaByColor: this\.groupImagesByColor\(good\.originGood\.variants[^)]*\),/,
|
||||||
|
'mediaByColor: this.groupImagesByColor(good.originGood.variants),',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(f, s);
|
||||||
|
console.log('patched OK');
|
||||||
@@ -39,6 +39,17 @@
|
|||||||
|
|
||||||
宽度低于 1000px 后分类栏变为抽屉;移动端商品网格降为两列或单列,国家筛选仅在自身区域横向滚动,不会撑宽页面。
|
宽度低于 1000px 后分类栏变为抽屉;移动端商品网格降为两列或单列,国家筛选仅在自身区域横向滚动,不会撑宽页面。
|
||||||
|
|
||||||
|
## 多源合并商品
|
||||||
|
|
||||||
|
后台支持把名称相同但工厂/仓库不同的多个 SDS 原产品合并为一个官网商品:
|
||||||
|
|
||||||
|
- 数据层:主源存 `goods.origin_good_id`,副源存中间表 `good_origin_goods`。
|
||||||
|
- 详情可达性:官网上通过**任一**关联原产品的 `sdsGoodId` 都能访问到该商品详情,即副源的旧链接不会 404。
|
||||||
|
- 变体合并:详情中的 SKU 是「主源变体 ∪ 全部副源变体」,按颜色归组展示媒体图;价格与详情页内容以主源为准。
|
||||||
|
- 后台维护入口:
|
||||||
|
- 配置弹窗(右栏拖拽/配置按钮):自动勾选同分类下同名兄弟原产品作为副源提交(`mergedOriginGoodIds`);
|
||||||
|
- 编辑弹窗「关联原产品」区:可搜索添加副源、移除副源、切换主源(切换后旧主源自动转为副源)。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ apps/api/
|
|||||||
| `TagGroup` | 标签分组(含 `groupName`、`groupColor`、`groupIcon`、`sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 |
|
| `TagGroup` | 标签分组(含 `groupName`、`groupColor`、`groupIcon`、`sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 |
|
||||||
| `Position` | 坑位:`(country, category)` 维度,关联多个 goods |
|
| `Position` | 坑位:`(country, category)` 维度,关联多个 goods |
|
||||||
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority` |
|
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority` |
|
||||||
|
| `GoodOriginGood` | 副源关联中间表(多对一):一个 Good 可关联多个副源 OriginGood;主源走 `goods.origin_good_id` 不入表。用于把名称相同但工厂/仓库不同的多个原产品合并为一个商品展示 |
|
||||||
| `User` | 后台用户(bcrypt 哈希) |
|
| `User` | 后台用户(bcrypt 哈希) |
|
||||||
| `SyncLog` | 同步任务日志,含 `SyncType`(CATEGORIES / PRODUCTS)和 `SyncStatus` |
|
| `SyncLog` | 同步任务日志,含 `SyncType`(CATEGORIES / PRODUCTS)和 `SyncStatus` |
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Generated
+236
-143
File diff suppressed because it is too large
Load Diff
@@ -29,3 +29,5 @@ Default to all products and countries with no logistics or craft selection. Neve
|
|||||||
Countries, categories, tags, tag groups, and icons are backend-owned. Preserve relative backend asset paths in the database and resolve them through `runtimeConfig.public.backendUrl` in `useProductCenter.ts`. Do not add country-name flag fallbacks or infer tag groups from numeric sort positions. Versioned Figma icon assets live under `apps/api/public/product-center/`; run `pnpm --filter @inkreach/api configure:product-center-icons` to apply them to matching existing records.
|
Countries, categories, tags, tag groups, and icons are backend-owned. Preserve relative backend asset paths in the database and resolve them through `runtimeConfig.public.backendUrl` in `useProductCenter.ts`. Do not add country-name flag fallbacks or infer tag groups from numeric sort positions. Versioned Figma icon assets live under `apps/api/public/product-center/`; run `pnpm --filter @inkreach/api configure:product-center-icons` to apply them to matching existing records.
|
||||||
|
|
||||||
The public goods API exposes `originGood.sdsGoodId` as the product `id`. Use that value for InkPOD detail links; never build an InkPOD URL from the local `goods.good_id` primary key.
|
The public goods API exposes `originGood.sdsGoodId` as the product `id`. Use that value for InkPOD detail links; never build an InkPOD URL from the local `goods.good_id` primary key.
|
||||||
|
|
||||||
|
A good may merge multiple origin goods (same-name variants across factories/warehouses): any associated origin's `sdsGoodId` resolves to the same detail via `/public/goods/:id`, and the detail's variants/media are the union of the primary and secondary origins. Price and detail content come from the primary origin.
|
||||||
|
|||||||
Reference in New Issue
Block a user