Author SHA1 Message Date
yeuimu 4bebb9e4b2 docs: document merged origin goods feature 2026-08-27 18:49:17 +08:00
yeuimu ac29a0c7bf feat(admin): show merged count badge on good nodes 2026-08-27 18:45:51 +08:00
yeuimu ac3a39f40d feat(admin): manage merged origin goods in good edit modal 2026-08-27 18:43:30 +08:00
yeuimu 491e74b905 feat(admin): add origin name grouping utils with vitest coverage 2026-08-27 18:40:22 +08:00
yeuimu 0aa6cbf1d9 feat(admin): suggest and merge sibling origin goods in config modal 2026-08-27 18:40:13 +08:00
yeuimu 697b82a64b fix(admin): resolve pre-existing template type errors blocking build 2026-08-27 18:40:08 +08:00
yeuimu f06dfffbda feat(public): resolve goods by secondary sds id and merge variants 2026-08-27 18:20:25 +08:00
yeuimu d9ecd04747 feat(origin-goods): include merged secondary references in tree stats 2026-08-27 18:18:29 +08:00
yeuimu 9c279ae393 feat(goods): support merged secondary origin goods in create/update/detail 2026-08-27 18:15:56 +08:00
yeuimu 848eed0b6f feat(db): add good_origin_goods junction table for merged origin goods 2026-08-27 18:09:00 +08:00
yeuimu 043d2463a6 docs(plan): add multi origin good merge design and implementation plan 2026-08-27 17:08:06 +08:00
yeuimu 005ab5b585 chore: restore files unintentionally deleted in 6c61a4e 2026-08-26 17:54:35 +08:00
yeuimu 6c61a4e871 feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
2026-08-26 14:23:09 +08:00
yeuimu be0b90e68f feat(security): HttpOnly cookie sessions, token revocation, and RBAC
- Add User.role (enum Role/ADMIN) and User.tokenVersion with migration
- Login now issues short-lived access token (30m default) + 7d refresh
  token, both embedding tokenVersion and a typ discriminator
- Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped
  to /auth); refresh token never leaves the cookie
- New endpoints: POST /auth/refresh (rotation), GET /auth/me,
  POST /auth/logout (bumps tokenVersion, revoking all tokens)
- JWT strategy accepts bearer or cookie, rejects refresh tokens, and
  verifies tokenVersion + user existence on every request
- Global RolesGuard: authenticated routes require ADMIN unless widened
  via @Roles(...)
- Admin SPA: session fully cookie-based, no token in localStorage;
  router guard restores session via /auth/me; axios auto-refreshes once
  on 401; stale localStorage keys cleaned up
2026-08-22 12:04:56 +08:00
yeuimu 755b40aded merge: security hardening fixes 2026-08-22 11:55:14 +08:00
yeuimu 9c1106586a fix(security): harden auth, upload, and API configuration
- Lock public registration to first-user bootstrap (403 afterwards)
- Require JwtAuthGuard on upload + whitelist png/jpg/webp/gif (SVG/XSS blocked)
- Add global throttling (login/register 5/min, upload 10/min)
- Add helmet security headers; serve uploads with nosniff
- Replace permissive CORS (origin:true+credentials) with CORS_ORIGINS whitelist
- Disable Swagger outside development; sanitize 500 error responses
- Enforce 32+ char JWT_SECRET; make token expiry configurable (TOKEN_EXPIRES_IN)
- Re-check user in DB on every JWT validation (revocation on user delete)
- Dummy bcrypt compare to prevent login user-enumeration via timing
- Map malformed BigInt inputs to 400 instead of 500
- Widen .gitignore to .env* and add apps/api/.env.example
- Disable Nuxt devtools and sourcemaps
2026-08-22 11:55:13 +08:00
yeuimu 9ed569f5bc fix(admin): simplify product forms 2026-08-21 14:54:08 +08:00
yeuimu b04623ebdd feat(goods): add editable custom products 2026-08-21 14:45:42 +08:00
yeuimu a4151607c5 fix(sync): hydrate all origin product details 2026-08-21 14:05:15 +08:00
yeuimu 4cc99f3f23 fix(docs): describe tags as grouped array 2026-08-21 11:44:25 +08:00
yeuimu 8b38d6fef7 refactor(api): group public tag filters 2026-08-21 11:34:57 +08:00
516 changed files with 78792 additions and 66034 deletions
+8 -44
View File
@@ -1,46 +1,10 @@
# Dependencies
node_modules
# Build outputs
dist
.output
.nuxt
.nitro
.data
.cache
# TypeScript
*.tsbuildinfo
# Logs
logs
node_modules/
dist/
.turbo/
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Environment
.env
.env.local
.env.development
.env.*.local
# OS
.DS_Store
# IDE
.idea
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
# Coverage
coverage
# Nuxt
.output
.nuxt
.nitro
.cache
.env.*
# Deployment runtime data (certs, ACME state, data dumps)
deploy/certbot/
deploy/data-dump.json
uploads/
+3 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
@@ -27,6 +28,7 @@
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.12",
"vitest": "^4.1.10",
"vue-tsc": "^3.2.8"
}
}
+10
View File
@@ -8,6 +8,8 @@ import type {
BatchPriorityRequest,
GoodsFilter,
PaginatedResult,
CreateCustomGoodRequest,
UpdateCustomGoodContentRequest,
} from '@/types'
export const goodsApi = {
@@ -26,6 +28,14 @@ export const goodsApi = {
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
updateGood: (id: string, data: UpdateGoodRequest) => {
return request.patch<any, Good>(`/goods/${id}`, data)
+27 -18
View File
@@ -1,5 +1,5 @@
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 router from '@/router'
@@ -9,22 +9,10 @@ const request: AxiosInstance = axios.create({
headers: {
'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
request.interceptors.response.use(
(response: AxiosResponse) => {
@@ -35,15 +23,36 @@ request.interceptors.response.use(
}
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) {
const { status, data } = error.response
switch (status) {
case 401:
ElMessage.error('Unauthorized, please login')
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
break
case 403:
+5
View File
@@ -11,12 +11,14 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
@@ -33,15 +35,18 @@ declare module 'vue' {
ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
+1 -1
View File
@@ -28,7 +28,7 @@ const routes: RouteRecordRaw[] = [
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory('/admin/'),
routes,
})
+23 -32
View File
@@ -4,49 +4,41 @@ import type { LoginRequest, User } from '@/types'
import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
// Token persisted to localStorage
const token = ref<string>(localStorage.getItem('token') || '')
// The session lives in HttpOnly cookies set by the API; nothing
// 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)
const user = ref<User | null>(loadUser())
// Tokens moved to HttpOnly cookies; clean up any stale values from the
// previous localStorage-based session.
localStorage.removeItem('token')
localStorage.removeItem('user')
const isLoggedIn = computed(() => !!token.value)
const isLoggedIn = computed(() => !!user.value)
function loadUser(): User | null {
const raw = localStorage.getItem('user')
if (!raw) return null
// 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 {
return JSON.parse(raw) as User
user.value = await authApi.getCurrentUser()
} catch {
return null
}
}
function setToken(newToken: string) {
token.value = newToken
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token')
user.value = null
}
}
function setUser(newUser: User | null) {
user.value = newUser
if (newUser) {
localStorage.setItem('user', JSON.stringify(newUser))
} else {
localStorage.removeItem('user')
}
}
async function login(payload: LoginRequest) {
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;
if (accessToken) setToken(accessToken);
if (userData) setUser(userData);
return res;
const res = await authApi.login(payload) as any
const userData: User | null = res.user ?? res.data?.user ?? null
sessionChecked.value = true
setUser(userData)
return res
}
async function fetchCurrentUser() {
@@ -61,14 +53,13 @@ export const useAuthStore = defineStore('auth', () => {
} catch {
// Ignore network errors during logout
}
setToken('')
setUser(null)
}
return {
token,
user,
isLoggedIn,
ensureSessionChecked,
login,
fetchCurrentUser,
logout,
+79
View File
@@ -42,6 +42,17 @@ export interface Good {
position?: Position
createdAt: 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 {
@@ -68,6 +79,49 @@ export interface OriginGoodVariant {
[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 {
originDetail: OriginGoodDetail | null
variants: OriginGoodVariant[]
@@ -82,6 +136,28 @@ export interface CreateGoodRequest {
tagIds?: number[]
positionId?: 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 {
@@ -93,6 +169,7 @@ export interface UpdateGoodRequest {
tagIds?: number[]
positionId?: number | null
goodPriority?: number
mergedOriginGoodIds?: number[]
}
export interface BatchCreateItem {
@@ -271,6 +348,8 @@ export interface OriginGood {
goodImage: string | null
goodPrice: string | null
sdsGoodId: string
source: 'SDS' | 'CUSTOM'
isCustom: boolean
sdsCategoryId: string | null
delisted?: boolean
createdAt: string
+46
View File
@@ -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);
});
});
+20
View File
@@ -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 }>({
categoryName: '',
categoryIcon: '',
parentCategoryId: '',
})
const dialogRules = {
@@ -146,7 +145,7 @@ onMounted(fetchTree)
>
<el-table-column prop="categoryName" label="名称" min-width="220" />
<el-table-column label="图标" width="100">
<template #default="{ row }: { row: CategoryTree }">
<template #default="{ row }: any">
<el-image
v-if="row.categoryIcon"
:src="row.categoryIcon"
@@ -158,17 +157,17 @@ onMounted(fetchTree)
</template>
</el-table-column>
<el-table-column label="父级">
<template #default="{ row }: { row: CategoryTree }">
<template #default="{ row }: any">
{{ row.parent?.categoryName || '-' }}
</template>
</el-table-column>
<el-table-column label="子级数" width="100">
<template #default="{ row }: { row: CategoryTree }">
<template #default="{ row }: any">
{{ row._count?.children ?? row.children?.length ?? 0 }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: CategoryTree }">
<template #default="{ row }: any">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
@@ -23,7 +23,7 @@ const filter = reactive<Required<CountryFilter>>({
async function fetchList() {
loading.value = true
try {
const res = await countriesApi.getCountriesList(filter)
await countriesApi.getCountriesList(filter)
const data = await countriesApi.getCountriesList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
@@ -147,7 +147,7 @@ onMounted(fetchList)
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="图标" width="80">
<template #default="{ row }: { row: Country }">
<template #default="{ row }: any">
<el-image
v-if="row.countryIcon"
:src="row.countryIcon"
@@ -160,12 +160,12 @@ onMounted(fetchList)
</el-table-column>
<el-table-column prop="countryName" label="名称" min-width="200" />
<el-table-column label="创建时间" width="180">
<template #default="{ row }: { row: Country }">
<template #default="{ row }: any">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Country }">
<template #default="{ row }: any">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
+485 -50
View File
@@ -4,10 +4,11 @@ import { useVirtualList } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Edit, Delete, Search, Top, Refresh,
FolderAdd, Aim, ArrowDown,
FolderAdd, Aim, ArrowDown, QuestionFilled,
} from '@element-plus/icons-vue'
import type {
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, Position,
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
MergedOriginGoodSummary,
OriginGoodsTreeResponse,
} from '@/types'
import { goodsApi } from '@/api/goods'
@@ -15,9 +16,9 @@ import { countriesApi } from '@/api/countries'
import { categoriesApi } from '@/api/categories'
import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups'
import { positionsApi } from '@/api/positions'
import { originGoodsApi } from '@/api/origin-goods'
import { syncApi } from '@/api/sync'
import { sameOriginGroup } from '@/utils/origin-name'
const mode = ref<'category' | 'country' | 'global'>('category')
const loading = ref(false)
@@ -170,7 +171,9 @@ function goodToNode(g: Good): any {
originGoodImage: g.originGood?.goodImage || null,
originGoodPrice: g.originGood?.goodPrice || 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,
isOG: true,
rawId: og.id,
parentId: 'rc-' + node.categoryId,
goodName: og.goodName,
goodImage: og.goodImage,
goodPrice: og.goodPrice,
@@ -364,11 +368,48 @@ const configForm = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '',
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) {
configOG.value = og
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 || '' }
if (dropTarget) {
if (mode.value === 'category') {
@@ -378,7 +419,6 @@ function openConfigModal(og: any, dropTarget: any) {
configForm.value.countryId = dropTarget.id
}
}
loadConfigPositions()
configVisible.value = true
}
@@ -392,19 +432,8 @@ function openConfigFromRightTree(data: any) {
}, null)
}
async function loadConfigPositions() {
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[]) {
function onConfigCascaderChange(val: any) {
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
loadConfigPositions()
}
async function handleConfigSubmit() {
@@ -415,7 +444,10 @@ async function handleConfigSubmit() {
await goodsApi.createGood({
goodName: configOG.value.goodName,
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),
categoryId: Number(configForm.value.categoryId),
tagIds: configForm.value.tagIds.map(Number),
@@ -430,12 +462,62 @@ async function handleConfigSubmit() {
} 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) ───
const editVisible = ref(false)
const editLoading = ref(false)
const editDetailLoading = ref(false)
const detailSyncing = ref(false)
const editGood = ref<Good | GoodDetail | null>(null)
const editMerged = ref<MergedOriginGoodSummary[]>([])
const editMergeSearch = ref('')
const originalOriginGoodId = ref<string>('')
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', tagIds: [] as string[], positionId: '',
@@ -455,9 +537,127 @@ const editSizeRows = computed(() => {
}))
})
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) {
editGood.value = g
editMerged.value = (g.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
originalOriginGoodId.value = g.originGoodId
editForm.value = {
id: g.id, goodName: g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || '',
@@ -470,7 +670,10 @@ async function openEdit(g: Good) {
editVisible.value = true
editDetailLoading.value = true
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 {
ElMessage.warning('商品详情加载失败,当前显示列表数据')
} finally {
@@ -495,12 +698,15 @@ async function handleSyncOriginDetail(data: any) {
}
async function handleSyncOneDetail() {
if (editIsCustom.value) return
const goodId = editGood.value?.originGood?.sdsGoodId
if (!goodId) return
detailSyncing.value = true
try {
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`)
await Promise.all([refreshLeftTree(), refreshRightTree()])
} catch (error: any) {
@@ -511,16 +717,88 @@ async function handleSyncOneDetail() {
}
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
try {
await goodsApi.updateGood(editForm.value.id, {
goodName: editForm.value.goodName,
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),
categoryId: Number(editForm.value.categoryId),
tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功')
editVisible.value = false
refreshLeftTree()
@@ -530,8 +808,9 @@ async function handleEditSubmit() {
} finally { editLoading.value = false }
}
function onEditCascaderChange(val: string[]) {
editForm.value.categoryId = val.length ? val[val.length - 1] : ''
function onEditCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
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)
.sort((a, b) => a.tagName.localeCompare(b.tagName))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag',
type: 'tag' as const,
label: t.tagName,
}))
@@ -993,7 +1272,7 @@ async function quickCreateCountry(targetForm: () => void) {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
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()
targetForm()
ElMessage.success('已创建并选中')
@@ -1006,7 +1285,7 @@ async function quickCreateTag(targetForm: () => void) {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '标签名称',
})
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()
targetForm()
ElMessage.success('已创建并选中')
@@ -1264,6 +1543,7 @@ onMounted(() => loadAll())
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<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" />
<el-radio-group v-model="mode" size="small" @change="onModeChange">
@@ -1343,23 +1623,25 @@ onMounted(() => loadAll())
</div>
</div>
<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>
</div>
</div>
</template>
<span class="good-name">{{ data.goodName }}</span>
<span v-if="data.mergedCount > 1" class="gv-merged-badge">×{{ data.mergedCount }}</span>
</el-tooltip>
</div>
<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 type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
</span>
</div>
<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.isCustom" class="good-tag">自定义</span>
<span v-if="data.country" class="good-country">{{ data.country }}</span>
<span
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-item label="国家">
<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-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>
<el-tag v-else>{{ allCountries.find(c => c.id === configForm.countryId)?.countryName }}</el-tag>
</el-form-item>
<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-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="图片">
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
</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-group>
</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>
</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>
<template #footer>
<el-button @click="configVisible = false">取消</el-button>
@@ -1579,15 +1872,50 @@ onMounted(() => loadAll())
</template>
</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) -->
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
<!-- Origin product reference -->
<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" />
<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-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">
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
@@ -1601,6 +1929,7 @@ onMounted(() => loadAll())
</div>
</div>
<el-button
v-if="!editIsCustom"
type="primary"
plain
size="small"
@@ -1609,6 +1938,31 @@ onMounted(() => loadAll())
@click="handleSyncOneDetail"
>同步详情</el-button>
</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-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="图片">
@@ -1616,14 +1970,14 @@ onMounted(() => loadAll())
</el-form-item>
<el-form-item label="国家">
<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-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>
</el-form-item>
<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 label="标签">
<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-group>
</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>
</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-tabs v-if="editOriginDetail" class="detail-tabs">
<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.englishName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生产周期">{{ editOriginDetail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
@@ -1649,7 +2027,8 @@ onMounted(() => loadAll())
</el-descriptions>
</el-tab-pane>
<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
v-for="column in editSizeColumns"
@@ -1661,7 +2040,8 @@ onMounted(() => loadAll())
</el-table>
</el-tab-pane>
<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 label="包装尺寸 (cm)" min-width="160">
<template #default="{ row }">
@@ -1673,7 +2053,28 @@ onMounted(() => loadAll())
</el-table>
</el-tab-pane>
<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="sizeName" label="尺码" width="90" />
<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-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
.good-name-col { flex: 1; min-width: 0; overflow: hidden; }
.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;
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 {
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-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-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; }
/* Config modal */
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
.config-og-name { font-weight: 600; font-size: 14px; }
.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 { max-height: 400px; overflow-y: auto; }
@@ -8,7 +8,6 @@ import type {
UpdatePositionRequest,
PositionFilter,
Country,
Category,
CategoryTree,
} from '@/types'
import { positionsApi } from '@/api/positions'
@@ -41,7 +40,7 @@ async function loadLookups() {
async function fetchList() {
loading.value = true
try {
const res = await positionsApi.getPositionsList(filter)
await positionsApi.getPositionsList(filter)
const data = await positionsApi.getPositionsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
@@ -88,8 +87,6 @@ const dialogLoading = ref(false)
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
indexVal: 0,
countryId: '',
categoryId: '',
})
const dialogRules = {
@@ -218,17 +215,17 @@ onMounted(async () => {
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
<el-table-column label="国家" min-width="160">
<template #default="{ row }: { row: Position }">
<template #default="{ row }: any">
{{ row.country?.countryName || '-' }}
</template>
</el-table-column>
<el-table-column label="分类" min-width="200">
<template #default="{ row }: { row: Position }">
<template #default="{ row }: any">
{{ getCategoryName(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Position }">
<template #default="{ row }: any">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
+4 -4
View File
@@ -28,7 +28,7 @@ async function refreshLogs() {
function syncTypeLabel(type: SyncType): string {
if (type === 'CATEGORIES') return '分类'
if (type === 'PRODUCT_DETAILS') return '品详情'
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
return '商品列表'
}
@@ -70,7 +70,7 @@ async function doSync(type: SyncType) {
const label = syncTypeLabel(type)
try {
await ElMessageBox.confirm(
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
'确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
)
@@ -136,7 +136,7 @@ onUnmounted(() => {
</div>
<div>
<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 class="sync-action-buttons">
@@ -166,7 +166,7 @@ onUnmounted(() => {
:icon="Refresh"
@click="handleSyncProductDetails"
>
同步品详情
同步全部原产品详情
</el-button>
</div>
</div>
+6 -7
View File
@@ -26,7 +26,7 @@ const filter = reactive<Required<TagFilter>>({
async function fetchList() {
loading.value = true
try {
const res = await tagsApi.getTagsList(filter)
await tagsApi.getTagsList(filter)
const data = await tagsApi.getTagsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
@@ -62,7 +62,6 @@ const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: numbe
tagColor: '#ff6800',
tagFontColor: '#ffffff',
timing: '',
tagGroupId: null,
})
const dialogRules = {
@@ -171,7 +170,7 @@ onMounted(() => {
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="预览" width="120">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<span
v-if="row.tagColor"
class="tag-preview"
@@ -182,7 +181,7 @@ onMounted(() => {
</el-table-column>
<el-table-column prop="tagName" label="名称" min-width="200" />
<el-table-column label="背景色" width="140">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagColor || '-' }}</span>
@@ -190,7 +189,7 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="字体色" width="140">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
@@ -198,14 +197,14 @@ onMounted(() => {
</template>
</el-table-column>
<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-else style="color: #999;">未分组</span>
</template>
</el-table-column>
<el-table-column prop="timing" label="定时" min-width="120" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
+1
View File
@@ -7,6 +7,7 @@ import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
base: '/admin/',
plugins: [
vue(),
AutoImport({
+20
View File
@@ -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
+4
View File
@@ -34,13 +34,16 @@
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.8.0",
"@types/multer": "^2.2.0",
"axios": "^1.6.5",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.7",
"express": "^4.21.0",
"helmet": "^8.3.0",
"multer": "^2.2.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -52,6 +55,7 @@
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@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;
+32
View File
@@ -6,6 +6,7 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
@@ -14,6 +15,11 @@ datasource db {
}
// ---------- Origin Goods ----------
enum OriginGoodSource {
SDS
CUSTOM
}
model OriginGood {
id BigInt @id @default(autoincrement()) @map("origin_good_id")
sdsGoodId String @unique @map("sds_good_id")
@@ -23,14 +29,17 @@ model OriginGood {
goodImage String? @map("good_image")
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
delisted Boolean @default(false)
source OriginGoodSource @default(SDS)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
goods Good[]
detail OriginGoodDetail?
variants OriginGoodVariant[]
mergedIntoGoods GoodOriginGood[]
@@index([sdsCategoryId])
@@index([source])
@@map("origin_goods")
}
@@ -207,6 +216,7 @@ model Good {
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
goodTags GoodTag[]
mergedOriginGoods GoodOriginGood[]
@@index([originGoodId])
@@index([countryId])
@@ -233,11 +243,33 @@ model GoodTag {
@@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) ----------
enum Role {
ADMIN
}
model User {
id BigInt @id @default(autoincrement())
username String @unique
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)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
+65
View File
@@ -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());
+106
View File
@@ -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());
+42
View File
@@ -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());
+23
View File
@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { RolesGuard } from './auth/guards/roles.guard';
import { CountriesModule } from './countries/countries.module';
import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module';
@@ -18,6 +21,14 @@ import { UploadModule } from './upload/upload.module';
ConfigModule.forRoot({
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,
AuthModule,
CountriesModule,
@@ -31,5 +42,17 @@ import { UploadModule } from './upload/upload.module';
PublicModule,
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 {}
+108 -13
View File
@@ -1,16 +1,28 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
Body,
Controller,
Get,
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 { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import {
LoginResponseDto,
UserPublicDto,
} from './dto/auth-response.dto';
import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
import type { AuthenticatedUser } from './strategies/jwt.strategy';
const ACCESS_TOKEN_COOKIE = 'ir_at';
const REFRESH_TOKEN_COOKIE = 'ir_rt';
const isProd = process.env.NODE_ENV === 'production';
@ApiTags('auth')
@Controller('auth')
@@ -19,19 +31,102 @@ export class AuthController {
@Post('register')
@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: 409, description: 'Username already exists' })
@ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' })
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
}
@Post('login')
@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: 401, description: 'Invalid credentials' })
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
async login(
@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 };
}
}
+6 -1
View File
@@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy';
if (!secret) {
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 {
secret,
signOptions: { expiresIn: '7d' },
signOptions: {
expiresIn: config.get<string>('TOKEN_EXPIRES_IN') ?? '7d',
},
};
},
}),
+119 -54
View File
@@ -1,102 +1,167 @@
import { Test } from '@nestjs/testing';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
describe('AuthService', () => {
let service: AuthService;
let prisma: PrismaService;
const createdUsernames: string[] = [];
let jwt: JwtService;
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 () => {
prisma = {
user: {
count: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({
secret: 'test-secret',
secret: 'a'.repeat(32),
signOptions: { expiresIn: '1h' },
}),
],
providers: [AuthService, PrismaService],
providers: [AuthService, { provide: PrismaService, useValue: prisma }],
}).compile();
service = moduleRef.get(AuthService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
jwt = moduleRef.get(JwtService);
});
afterAll(async () => {
// Cleanup created test users
if (createdUsernames.length) {
await prisma.user.deleteMany({
where: { username: { in: createdUsernames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
beforeEach(() => {
jest.clearAllMocks();
});
describe('register', () => {
it('creates a new user and stores a hashed password', async () => {
const username = `test_reg_${Date.now()}`;
createdUsernames.push(username);
it('creates the first user with a hashed password', async () => {
prisma.user.count.mockResolvedValueOnce(0);
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.id).toBeTruthy();
expect(user.username).toBe('alice');
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 } });
expect(stored).not.toBeNull();
expect(stored?.passwordHash).not.toBe('plain-pwd');
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
expect(matches).toBe(true);
it('refuses registration once a user exists (bootstrap lock)', async () => {
prisma.user.count.mockResolvedValueOnce(1);
await expect(
service.register({ username: 'mallory', password: 'evil-pwd' }),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('throws ConflictException for duplicate usernames', async () => {
const username = `test_dup_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'pwd1234' });
prisma.user.count.mockResolvedValueOnce(0);
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
await expect(
service.register({ username, password: 'pwd5678' }),
service.register({ username: 'alice', password: 'pwd5678' }),
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('login', () => {
it('returns an access token for valid credentials', async () => {
const username = `test_login_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'correct-pwd' });
it('returns access + refresh tokens with tokenVersion and type', async () => {
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
const result = await service.login({ username: 'alice', password: 'plain-pwd' });
const result = await service.login({ username, password: 'correct-pwd' });
expect(result.accessToken).toEqual(expect.any(String));
const parts = result.accessToken.split('.');
expect(parts.length).toBe(3);
expect(result.user.username).toBe(username);
expect(result.user.username).toBe('alice');
const access = jwt.decode(result.accessToken) as Record<string, unknown>;
expect(access.typ).toBe('access');
expect(access.tv).toBe(0);
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 () => {
const username = `test_wrong_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'right-pwd' });
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
await expect(
service.login({ username, password: 'wrong-pwd' }),
service.login({ username: 'alice', password: 'wrong-pwd' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws UnauthorizedException for unknown user', async () => {
prisma.user.findUnique.mockResolvedValueOnce(null);
await expect(
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
service.login({ username: 'no-such-user', password: 'whatever' }),
).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 } },
});
});
});
});
+108 -12
View File
@@ -1,5 +1,6 @@
import {
ConflictException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
@@ -13,16 +14,25 @@ import type { JwtPayload } from './strategies/jwt.strategy';
export interface PublicUser {
id: string;
username: string;
role: string;
createdAt: string;
}
export interface LoginResult {
accessToken: string;
refreshToken: string;
user: PublicUser;
}
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()
export class AuthService {
@@ -32,10 +42,15 @@ export class AuthService {
) {}
/**
* Registers a brand-new admin user. Throws {@link ConflictException}
* if the username is already taken.
* Bootstrap-only registration: allowed just while the instance has no
* users. Once an admin exists the endpoint refuses to create accounts
* (use database seeding / an operator flow instead).
*/
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({
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> {
const user = await this.prisma.user.findUnique({
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) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
throw new UnauthorizedException();
}
return this.toPublic(user);
}
private async signAccessToken(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: 'access',
};
const accessToken = await this.jwt.signAsync(payload, {
expiresIn: TOKEN_EXPIRES_IN,
return this.jwt.signAsync(payload, {
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: {
id: bigint;
username: string;
role: string;
createdAt: Date;
}): PublicUser {
return {
id: user.id.toString(),
username: user.username,
role: user.role,
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,
);
});
});
+33
View File
@@ -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;
}
}
+46 -5
View File
@@ -2,26 +2,51 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
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.
*
* `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 {
sub: string;
username: string;
role?: string;
tv?: number;
typ?: 'access' | 'refresh';
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
const secret = config.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET is not configured');
}
if (secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters');
}
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,
secretOrKey: secret,
});
@@ -29,12 +54,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
/**
* 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) {
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 status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
exception instanceof HttpException ? 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 error = 'InternalServerError';
@@ -51,11 +66,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
message = exception.message;
}
} else if (exception instanceof Error) {
message = exception.message;
error = exception.name;
// Unexpected errors (Prisma, driver, ...) may contain SQL or
// 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(
`${request.method} ${request.url} -> ${status} ${message}`,
exception instanceof Error ? exception.stack : undefined,
@@ -15,6 +15,13 @@ export class BatchCreateItemDto {
@Min(1)
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 })
@IsOptional()
@IsInt()
@@ -20,6 +20,13 @@ export class CreateGoodDto {
@Min(1)
originGoodId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
mergedOriginGoodIds?: number[];
@ApiProperty()
@IsInt()
@Min(1)
+236
View File
@@ -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),
) {}
+78 -7
View File
@@ -9,6 +9,7 @@ export interface GoodRelations {
originGood?: {
id: bigint;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
goodName: string | null;
goodImage: string | null;
goodPrice: unknown;
@@ -32,6 +33,31 @@ export interface GoodRelations {
_count?: { variants: number };
} | 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 {
@@ -80,6 +106,9 @@ export class GoodDto {
@ApiProperty({ required: false, type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
@ApiProperty({ required: false, type: Array })
mergedOriginGoods!: MergedOriginGoodSummary[];
@ApiProperty({ required: false, nullable: true })
position?: { id: string; indexVal: number } | null;
@@ -87,6 +116,8 @@ export class GoodDto {
originGood?: {
id: string;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
isCustom: boolean;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
@@ -144,6 +175,21 @@ export class GoodDto {
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
? {
id: rel.position.id.toString(),
@@ -154,6 +200,8 @@ export class GoodDto {
? {
id: rel.originGood.id.toString(),
sdsGoodId: rel.originGood.sdsGoodId,
source: rel.originGood.source,
isCustom: rel.originGood.source === 'CUSTOM',
goodName: rel.originGood.goodName,
goodImage: rel.originGood.goodImage,
goodPrice:
@@ -189,16 +237,39 @@ export class GoodDetailDto extends GoodDto {
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
const base = GoodDto.from(good, rel);
const detail = rel.originGood?.detail;
const toAnnotated = (
variant: Record<string, unknown>,
originGoodId: string,
originGoodName: string | null,
) => ({
...variant,
price:
variant.price === null || variant.price === undefined
? null
: (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: (rel.originGood?.variants ?? []).map((variant) => ({
...variant,
price:
variant.price === null || variant.price === undefined
? null
: (variant.price as { toString(): string }).toString(),
})),
variants: mergedVariants,
};
}
}
@@ -19,6 +19,13 @@ export class UpdateGoodDto {
@Min(1)
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 })
@IsOptional()
@IsInt()
+19
View File
@@ -22,6 +22,10 @@ import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
import {
CreateCustomGoodDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
@ApiTags('goods')
@ApiBearerAuth()
@@ -54,6 +58,21 @@ export class GoodsController {
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')
@ApiOperation({ summary: 'Get one good with relations' })
findOne(@Param('id', ParseIntPipe) id: string) {
+174
View File
@@ -119,6 +119,51 @@ describe('GoodsService', () => {
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 () => {
const result = await service.findAll({
page: 1,
@@ -216,6 +261,135 @@ describe('GoodsService', () => {
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 () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
+323 -5
View File
@@ -12,6 +12,13 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
import { SyncService } from '../sync/sync.service';
import { randomUUID } from 'crypto';
import {
CreateCustomGoodDto,
CustomGoodDetailDto,
CustomGoodVariantDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
const GOOD_INCLUDE = {
country: true,
@@ -26,6 +33,18 @@ const GOOD_INCLUDE = {
},
},
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;
@Injectable()
@@ -68,6 +87,7 @@ export class GoodsService {
position: g.position,
originGood: g.originGood,
goodTags: g.goodTags,
mergedOriginGoods: g.mergedOriginGoods,
})),
total,
page,
@@ -88,11 +108,17 @@ export class GoodsService {
position: good.position,
originGood: good.originGood,
goodTags: good.goodTags,
mergedOriginGoods: good.mergedOriginGoods,
});
}
async create(dto: CreateGoodDto): Promise<GoodDto> {
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 created = await tx.good.create({
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({
where: { id: created.id },
include: GOOD_INCLUDE,
@@ -124,16 +158,133 @@ export class GoodsService {
position: result.position,
originGood: result.originGood,
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);
}
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> {
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 = {};
if (dto.goodName !== undefined) data.goodName = dto.goodName;
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({
where: { id },
data,
@@ -186,17 +348,36 @@ export class GoodsService {
position: updated.position,
originGood: updated.originGood,
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);
}
return result;
}
async remove(id: bigint): Promise<{ id: string }> {
await this.findOne(id);
await this.prisma.good.delete({ where: { id } });
const good = await this.prisma.good.findUnique({
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() };
}
@@ -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({
where: { id: row.id },
include: GOOD_INCLUDE,
@@ -269,13 +468,18 @@ export class GoodsService {
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
mergedOriginGoods: result.mergedOriginGoods,
}));
}
return created;
});
for (const goodId of new Set(
result
.filter((item) => !item.originGood?.hasDetail)
.filter(
(item) =>
item.originGood?.source === 'SDS' &&
!item.originGood.hasDetail,
)
.map((item) => item.originGood?.sdsGoodId)
.filter((id): id is string => Boolean(id)),
)) {
@@ -312,6 +516,31 @@ export class GoodsService {
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) {
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
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`);
}
}
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),
},
});
}
}
}
+44 -16
View File
@@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import * as cookieParserModule from 'cookie-parser';
import { json } from 'express';
import { join } from 'path';
import { AppModule } from './app.module';
@@ -28,15 +30,39 @@ async function bootstrap() {
}),
);
// CORS
app.enableCors({
origin: true,
credentials: true,
});
// Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...)
// Static assets (/uploads, /assets) are embedded cross-origin by other
// sites, so CORP must allow cross-origin reads.
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'), {
prefix: '/uploads/',
setHeaders: (res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
},
});
app.useStaticAssets(join(process.cwd(), 'public'), {
prefix: '/assets/',
@@ -55,21 +81,23 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
// Swagger
const config = new DocumentBuilder()
.setTitle('InkReach Product Center API')
.setDescription('Backend API for InkReach Product Center')
.setVersion('1.0')
.addBearerAuth()
.build();
// Swagger is only exposed outside production to avoid leaking the
// full admin API surface.
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('InkReach Product Center API')
.setDescription('Backend API for InkReach Product Center')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
const port = process.env.PORT ?? 3001;
await app.listen(port, '0.0.0.0');
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
@@ -77,4 +77,69 @@ describe('OriginGoodsService', () => {
expect(result.total).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> {
const { page, pageSize, keyword } = query;
const where: Prisma.OriginGoodWhereInput = keyword
? { goodName: { contains: keyword, mode: 'insensitive' } }
: {};
const where: Prisma.OriginGoodWhereInput = {
source: 'SDS',
...(keyword
? { goodName: { contains: keyword, mode: 'insensitive' as const } }
: {}),
};
const [total, rows] = await this.prisma.$transaction([
this.prisma.originGood.count({ where }),
@@ -111,7 +114,7 @@ export class OriginGoodsService {
* under a synthetic "未分类" root node.
*/
async getTree(): Promise<OriginGoodsTreeResponse> {
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] =
await Promise.all([
this.prisma.category.findMany({
where: { sdsCategoryId: { not: null } },
@@ -124,7 +127,7 @@ export class OriginGoodsService {
},
}),
this.prisma.originGood.findMany({
where: { delisted: false },
where: { delisted: false, source: 'SDS' },
orderBy: { goodName: 'asc' },
include: { detail: true, _count: { select: { variants: true } } },
}),
@@ -141,7 +144,12 @@ export class OriginGoodsService {
}),
this.prisma.goodTag.findMany({
select: {
good: { select: { originGoodId: true } },
good: {
select: {
originGoodId: true,
mergedOriginGoods: { select: { originGoodId: true } },
},
},
tag: {
select: {
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>();
configCounts.forEach((c) =>
countMap.set(c.originGoodId.toString(), c._count._all),
);
const countryMap = new Map<string, string[]>();
goodsWithCountries.forEach((g) => {
const key = g.originGoodId.toString();
const name = g.country?.countryName;
if (!name) return;
const arr = countryMap.get(key);
if (arr) arr.push(name);
else countryMap.set(key, [name]);
// Secondary (merged) references count towards configured status too.
mergedCounts.forEach((c) => {
const key = c.originGoodId.toString();
countMap.set(key, (countMap.get(key) ?? 0) + c._count._all);
});
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 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) => {
const key = gt.good.originGoodId.toString();
const tagInfo = {
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
@@ -182,11 +221,10 @@ export class OriginGoodsService {
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
sortOrder: gt.tag.sortOrder,
};
const arr = tagMap.get(key);
if (arr) {
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
} else {
tagMap.set(key, [tagInfo]);
addTag(gt.good.originGoodId.toString(), tagInfo);
// A good's tags also mark its secondary origin goods as configured.
for (const m of gt.good.mergedOriginGoods) {
addTag(m.originGoodId.toString(), tagInfo);
}
});
@@ -20,6 +20,14 @@ export class PublicGoodDetailDto extends PublicGoodDto {
@ApiProperty({ nullable: true, type: Object })
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 })
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 { Transform, Type } from 'class-transformer';
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
import { plainToInstance, Transform, Type } from 'class-transformer';
import {
IsArray,
IsIn,
@@ -7,7 +7,8 @@ import {
IsNumberString,
IsOptional,
IsString,
Matches,
ValidateNested,
ArrayNotEmpty,
Max,
Min,
} from 'class-validator';
@@ -21,6 +22,32 @@ const stringList = ({ value }: { value: unknown }): string[] | undefined => {
.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 {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@@ -47,21 +74,12 @@ export class PublicQueryGoodDto {
@IsNumberString()
categoryId?: string;
@ApiProperty({
required: false,
type: [String],
description:
'标签筛选项,格式为 tagGroupId:tagId;支持重复参数或逗号分隔。同组 OR,跨组 AND',
example: ['1:11', '1:12', '2:25'],
})
@ApiHideProperty()
@IsOptional()
@Transform(stringList)
@Transform(tagFilters)
@IsArray()
@Matches(/^\d+:\d+$/, {
each: true,
message: 'tagIds 每个元素必须为 tagGroupId:tagId',
})
tagIds?: string[];
@ValidateNested({ each: true })
tags?: PublicTagFilterDto[];
@ApiProperty({ required: false })
@IsOptional()
+29 -1
View File
@@ -4,18 +4,28 @@ import {
Param,
Query,
} 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 {
PublicCountryQueryDto,
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto';
import { PublicTagDto } from './dto/public-tag.dto';
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
import { PublicGoodDto } from './dto/public-good.dto';
@ApiTags('public')
@ApiExtraModels(PublicTagFilterDto)
@Controller('public')
export class PublicController {
constructor(private readonly service: PublicService) {}
@@ -47,6 +57,24 @@ export class PublicController {
@Get('goods')
@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) {
return this.service.getGoods(query);
}
+87 -8
View File
@@ -247,9 +247,12 @@ describe('PublicService', () => {
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
tagIds: filterTagIds
.slice(0, 2)
.map((tagId) => `${filterGroupIds[0]}:${tagId}`),
tags: [
{
tagGroupId: filterGroupIds[0].toString(),
tagIds: filterTagIds.slice(0, 2).map(String),
},
],
});
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
@@ -260,10 +263,16 @@ describe('PublicService', () => {
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
tagIds: filterTagIds.map(
(tagId, index) =>
`${index < 2 ? filterGroupIds[0] : filterGroupIds[1]}:${tagId}`,
),
tags: [
{
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)).not.toContain(`Pub Mid ${stamp}`);
@@ -274,7 +283,12 @@ describe('PublicService', () => {
service.getGoods({
page: 1,
pageSize: 20,
tagIds: [`${filterGroupIds[1]}:${filterTagIds[0]}`],
tags: [
{
tagGroupId: filterGroupIds[1].toString(),
tagIds: [filterTagIds[0].toString()],
},
],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -292,6 +306,36 @@ describe('PublicService', () => {
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 () => {
const first = await service.getGoods({
page: 1,
@@ -335,4 +379,39 @@ describe('PublicService', () => {
const sortOrders = groups.map((g) => g.sortOrder);
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);
}
});
});
});
+103 -12
View File
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import {
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto';
import { PublicCategoryNodeDto } from './dto/public-category.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 }] },
},
},
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 } } } },
} satisfies Prisma.GoodInclude;
@@ -145,9 +156,7 @@ export class PublicService {
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
}
const tagFilters = await this.buildTagGroupFilters([
...new Set(query.tagIds ?? []),
]);
const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
if (tagFilters.length) where.AND = tagFilters;
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
@@ -199,14 +208,26 @@ export class PublicService {
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
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,
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
});
if (!good) {
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[]> {
@@ -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 {
const base = this.toPublicGood(good);
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 {
...base,
productCode: detail?.productCode ?? null,
@@ -293,10 +379,11 @@ export class PublicService {
pictureRequest: detail?.pictureRequest ?? null,
},
media: (detail?.media as Record<string, unknown> | null) ?? null,
mediaByColor: this.groupImagesByColor(allVariants),
options: (detail?.options as Record<string, unknown> | null) ?? null,
sizeChart: (detail?.sizeChart 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,
sku: variant.sku,
sizeId: variant.sizeId,
@@ -319,16 +406,20 @@ export class PublicService {
}
private async buildTagGroupFilters(
selectedTagValues: string[],
selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> {
const selections = selectedTagValues.map((value) => {
const [tagGroupId, tagId] = value.split(':');
if (!tagGroupId || !tagId || !/^\d+$/.test(tagGroupId) || !/^\d+$/.test(tagId)) {
const selections = selectedGroups.flatMap((group) => {
if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
throw new BadRequestException(
'tagIds 每个元素必须 tagGroupId:tagId',
'tags 每个元素必须包含合法的 tagGroupIdtagIds',
);
}
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 selected = uniqueTagIds.length
+1 -1
View File
@@ -38,7 +38,7 @@ export class SyncController {
}
@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() {
return this.service.startProductDetailSync();
}
+64
View File
@@ -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);
});
});
+109 -27
View File
@@ -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 { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@@ -23,8 +28,6 @@ export interface ProductSyncResult {
total: number;
leafCategories: number;
delisted: number;
detailsSynced: number;
detailFailures: number;
}
/**
@@ -110,6 +113,16 @@ export class SyncService {
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 }> {
if (this.running.details) {
return { message: 'Product detail sync already in progress' };
@@ -341,11 +354,19 @@ export class SyncService {
const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size);
if (runDelist) {
const delistedResult = await this.prisma.originGood.updateMany({
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false },
where: {
source: 'SDS',
sdsGoodId: { notIn: [...seenSdsGoodIds] },
delisted: false,
},
data: { delisted: true },
});
const reactivatedResult = await this.prisma.originGood.updateMany({
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true },
where: {
source: 'SDS',
sdsGoodId: { in: [...seenSdsGoodIds] },
delisted: true,
},
data: { delisted: false },
});
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({
where: { id: log.id },
data: {
status: 'SUCCESS',
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 {
@@ -376,8 +392,6 @@ export class SyncService {
total,
leafCategories: leafRows.length,
delisted: delistedCount,
detailsSynced: detailResult.synced,
detailFailures: detailResult.failed,
};
} catch (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) {
throw new Error('Product detail sync already in progress');
}
@@ -411,13 +429,20 @@ export class SyncService {
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
});
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({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `synced=${result.synced} failed=${result.failed}`,
message: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
},
});
return result;
@@ -440,11 +465,14 @@ export class SyncService {
}> {
const originGood = await this.prisma.originGood.findUnique({
where: { sdsGoodId: goodId },
select: { id: true },
select: { id: true, source: true },
});
if (!originGood) {
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 normalized = normalizeProductDetail(upstream);
await this.persistProductDetail(originGood.id, upstream);
@@ -463,25 +491,72 @@ export class SyncService {
}
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
const configured = await this.prisma.originGood.findMany({
where: { delisted: false, goods: { some: {} } },
const result = await this.syncMatchingProductDetails({
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 },
orderBy: { id: 'asc' },
});
let synced = 0;
let failed = 0;
for (const originGood of configured) {
try {
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
await this.persistProductDetail(originGood.id, upstream);
synced++;
} catch (error) {
let processed = 0;
for (const originGood of originGoods) {
let lastError: unknown;
let succeeded = false;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
await this.persistProductDetail(originGood.id, upstream);
synced++;
succeeded = true;
break;
} catch (error) {
lastError = error;
}
}
if (!succeeded) {
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}`);
}
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<{
@@ -533,6 +608,13 @@ export class SyncService {
const { variants, ...detail } = normalized;
await this.prisma.$transaction(async (tx) => {
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({
where: { originGoodId },
create: {
+15 -3
View File
@@ -1,33 +1,45 @@
import {
Controller,
Post,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { randomUUID } from 'crypto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
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')
export class UploadController {
@Post('image')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: UPLOAD_DIR,
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}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new BadRequestException('仅支持图片文件'), false);
if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
}
cb(null, true);
},
View File
View File
@@ -316,7 +316,15 @@ export function useProductCenter() {
if (query.countryId) params.countryId = query.countryId;
if (query.categoryId) params.categoryId = query.categoryId;
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();
+2 -1
View File
@@ -1,6 +1,7 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
devtools: { enabled: false },
sourcemap: { server: false, client: false },
modules: ['@nuxtjs/seo'],
+8318
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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
+30
View File
@@ -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"]
+56
View File
@@ -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:
+68
View File
@@ -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/;
}
}
+41
View File
@@ -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');
+11
View File
@@ -39,6 +39,17 @@
宽度低于 1000px 后分类栏变为抽屉;移动端商品网格降为两列或单列,国家筛选仅在自身区域横向滚动,不会撑宽页面。
## 多源合并商品
后台支持把名称相同但工厂/仓库不同的多个 SDS 原产品合并为一个官网商品:
- 数据层:主源存 `goods.origin_good_id`,副源存中间表 `good_origin_goods`
- 详情可达性:官网上通过**任一**关联原产品的 `sdsGoodId` 都能访问到该商品详情,即副源的旧链接不会 404。
- 变体合并:详情中的 SKU 是「主源变体 ∪ 全部副源变体」,按颜色归组展示媒体图;价格与详情页内容以主源为准。
- 后台维护入口:
- 配置弹窗(右栏拖拽/配置按钮):自动勾选同分类下同名兄弟原产品作为副源提交(`mergedOriginGoodIds`);
- 编辑弹窗「关联原产品」区:可搜索添加副源、移除副源、切换主源(切换后旧主源自动转为副源)。
## 验证
```bash
+1
View File
@@ -84,6 +84,7 @@ apps/api/
| `TagGroup` | 标签分组(含 `groupName``groupColor``groupIcon``sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 |
| `Position` | 坑位:`(country, category)` 维度,关联多个 goods |
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority` |
| `GoodOriginGood` | 副源关联中间表(多对一):一个 Good 可关联多个副源 OriginGood;主源走 `goods.origin_good_id` 不入表。用于把名称相同但工厂/仓库不同的多个原产品合并为一个商品展示 |
| `User` | 后台用户(bcrypt 哈希) |
| `SyncLog` | 同步任务日志,含 `SyncType`CATEGORIES / PRODUCTS)和 `SyncStatus` |
File diff suppressed because it is too large Load Diff
+236 -143
View File
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.
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.