Author SHA1 Message Date
yeuimu 3bbd696b58 chore: ignore deploy/backups 2026-08-27 14:57:19 +08:00
yeuimu b947d88c2c docs(data-cleaning): finalize transformation rules and API-driven approach 2026-08-27 14:57:19 +08:00
yeuimu 1a3858d30b docs(data-cleaning): document transformation rule survey findings 2026-08-27 11:13:05 +08:00
yeuimu a1928a2050 docs(plans): add goods data cleaning feature plan and pipeline folder 2026-08-26 18:36:27 +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
yeuimu 4963a5c463 docs: update local API startup command 2026-08-21 11:01:02 +08:00
yeuimu a43353aa98 fix(dev): support admin access over LAN 2026-08-21 10:53:47 +08:00
yeuimu 6e5f7edbb7 chore: update admin generated types and env ignore 2026-08-21 10:39:23 +08:00
yeuimu 437d9ca93b refactor(api): pair public tag filters with groups 2026-08-21 10:24:35 +08:00
yeuimu d375df810d feat(admin): manage and sync product details 2026-08-21 10:18:57 +08:00
yeuimu 7d09077f1d feat(api): add mini program catalog endpoints and product details 2026-08-21 02:11:20 +08:00
yeuimu fcbf8bb494 chore: stop tracking env files (.env, .env.development)
Credentials and per-environment config should not be committed.
Files stay on disk locally; .gitignore now covers .env.development.
2026-08-20 18:44:44 +08:00
522 changed files with 77894 additions and 65265 deletions
-1
View File
@@ -1 +0,0 @@
DATABASE_URL=postgresql://postgres:yoyoki219765.@localhost:5432/inkreach-official
+11 -42
View File
@@ -1,45 +1,14 @@
# 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.*.local
.env.*
# Deployment runtime data (certs, ACME state, data dumps)
deploy/certbot/
deploy/data-dump.json
uploads/
# OS
.DS_Store
# IDE
.idea
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
# Coverage
coverage
# Nuxt
.output
.nuxt
.nitro
.cache
# Data cleaning runtime artifacts (production snapshots, exports, reports)
data-cleaning/runs/
deploy/backups/
+1 -1
View File
@@ -51,7 +51,7 @@ PORT=3001
cd apps/api
pnpm prisma:generate
pnpm prisma:migrate
pnpm start:dev
pnpm --filter @inkreach/api dev
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
# Terminal 2:官网
-1
View File
@@ -1 +0,0 @@
VITE_API_BASE=/api
+12 -1
View File
@@ -1,12 +1,15 @@
import request from './request'
import type {
Good,
GoodDetail,
CreateGoodRequest,
UpdateGoodRequest,
BatchCreateGoodsRequest,
BatchPriorityRequest,
GoodsFilter,
PaginatedResult,
CreateCustomGoodRequest,
UpdateCustomGoodContentRequest,
} from '@/types'
export const goodsApi = {
@@ -17,7 +20,7 @@ export const goodsApi = {
// Get good by id
getGoodById: (id: string) => {
return request.get<any, Good>(`/goods/${id}`)
return request.get<any, GoodDetail>(`/goods/${id}`)
},
// Create good
@@ -25,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:
+10
View File
@@ -10,6 +10,16 @@ export const syncApi = {
return request.post<any, { message: string }>('/sync/categories')
},
syncProductDetails: () => {
return request.post<any, { message: string }>('/sync/product-details')
},
syncOneProductDetail: (goodId: string) => {
return request.post<any, { goodId: string; variants: number; detailSyncedAt: string }>(
`/sync/products/${goodId}/detail`,
)
},
getSyncStatus: (limit?: number) => {
return request.get<any, SyncLog[]>('/sync/status', {
params: limit ? { limit } : undefined,
+7
View File
@@ -11,6 +11,7 @@ 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']
@@ -19,6 +20,8 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
@@ -31,6 +34,7 @@ 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']
@@ -40,6 +44,9 @@ declare module 'vue' {
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']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
+1 -1
View File
@@ -28,7 +28,7 @@ const routes: RouteRecordRaw[] = [
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory('/admin/'),
routes,
})
+26 -35
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())
const isLoggedIn = computed(() => !!token.value)
function loadUser(): User | null {
const raw = localStorage.getItem('user')
if (!raw) return null
try {
return JSON.parse(raw) as User
} catch {
return null
}
}
function setToken(newToken: string) {
token.value = newToken
if (newToken) {
localStorage.setItem('token', newToken)
} else {
// 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(() => !!user.value)
// Restore the session once per app start. The router guard awaits this
// so a page refresh on a protected route does not bounce to /login.
async function ensureSessionChecked() {
if (sessionChecked.value) return
sessionChecked.value = true
try {
user.value = await authApi.getCurrentUser()
} catch {
user.value = null
}
}
function setUser(newUser: User | null) {
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,
+111 -7
View File
@@ -44,6 +44,78 @@ export interface Good {
updatedAt: string
}
export interface OriginGoodDetail {
productCode?: string | null
englishName?: string | null
productionCycleHours?: number | null
minWeightG?: string | null
productionProcess?: string | null
materialDescription?: string | null
sizeChart?: { columns?: Array<{ key: string; name: string }>; rows?: any[] } | null
packageSpecs?: { rows?: any[] } | null
syncedAt?: string | null
[key: string]: unknown
}
export interface OriginGoodVariant {
sdsVariantId: string
sku: string
sizeName?: string | null
colorName?: string | null
colorHex?: string | null
price?: string | null
enabled: boolean
[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[]
}
export interface CreateGoodRequest {
goodName: string
goodImage?: string
@@ -55,6 +127,27 @@ export interface CreateGoodRequest {
goodPriority?: 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 {
goodName?: string
goodImage?: string | null
@@ -242,10 +335,18 @@ export interface OriginGood {
goodImage: string | null
goodPrice: string | null
sdsGoodId: string
source: 'SDS' | 'CUSTOM'
isCustom: boolean
sdsCategoryId: string | null
delisted?: boolean
createdAt: string
updatedAt: string
hasDetail?: boolean
detailSyncedAt?: string | null
variantCount?: number
sizeRowCount?: number
packageRowCount?: number
productCode?: string | null
}
// Origin Goods Tree types
@@ -259,6 +360,11 @@ export interface OriginGoodsTreeNode {
configuredCount: number
configuredCountries: string[]
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
hasDetail: boolean
detailSyncedAt: string | null
variantCount: number
sizeRowCount: number
packageRowCount: number
}
export interface OriginGoodsTreeCategoryNode {
@@ -280,13 +386,11 @@ export interface OriginGoodsTreeResponse {
// Sync types
export interface SyncLog {
id: string
type: 'CATEGORY' | 'PRODUCT'
status: 'SUCCESS' | 'FAILED'
message?: string
startTime: string
endTime?: string
errorCount?: number
createdAt: string
type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS'
status: 'RUNNING' | 'SUCCESS' | 'FAILED'
message: string | null
startedAt: string
finishedAt: string | null
}
// Filter types
+446 -37
View File
@@ -3,11 +3,11 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useVirtualList } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Edit, Delete, Search, Top,
Plus, Edit, Delete, Search, Top, Refresh,
FolderAdd, Aim, ArrowDown,
} from '@element-plus/icons-vue'
import type {
CategoryTree, Country, Tag, TagGroup, Good, Position,
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
OriginGoodsTreeResponse,
} from '@/types'
import { goodsApi } from '@/api/goods'
@@ -15,8 +15,8 @@ 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'
const mode = ref<'category' | 'country' | 'global'>('category')
const loading = ref(false)
@@ -53,6 +53,7 @@ const leftPct = ref(55)
const showAllLeft = ref(false)
const showAllRight = ref(false)
const showUnconfiguredOnly = ref(false)
const syncingOriginGoodIds = ref(new Set<string>())
function onSplitterMouseDown(e: MouseEvent) {
e.preventDefault()
@@ -168,7 +169,8 @@ 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,
originDelisted: g.originGood?.source === 'SDS' && !activeOriginGoodIds.value.has(String(g.originGoodId)),
}
}
@@ -264,6 +266,11 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
sdsGoodId: og.sdsGoodId,
configuredCount: og.configuredCount ?? 0,
configuredCountries: og.configuredCountries ?? [],
hasDetail: Boolean(og.hasDetail),
detailSyncedAt: og.detailSyncedAt ?? null,
variantCount: og.variantCount ?? 0,
sizeRowCount: og.sizeRowCount ?? 0,
packageRowCount: og.packageRowCount ?? 0,
}))
return {
id: 'rc-' + node.categoryId,
@@ -357,7 +364,6 @@ const configForm = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '',
tagIds: [] as string[], positionId: '', goodImage: '',
})
const configPositions = ref<Position[]>([])
function openConfigModal(og: any, dropTarget: any) {
configOG.value = og
@@ -371,7 +377,6 @@ function openConfigModal(og: any, dropTarget: any) {
configForm.value.countryId = dropTarget.id
}
}
loadConfigPositions()
configVisible.value = true
}
@@ -385,19 +390,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[]) {
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
loadConfigPositions()
}
async function handleConfigSubmit() {
@@ -423,16 +417,149 @@ 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 editGood = ref<Good | null>(null)
const editDetailLoading = ref(false)
const detailSyncing = ref(false)
const editGood = ref<Good | GoodDetail | null>(null)
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', tagIds: [] as string[], positionId: '',
})
function openEdit(g: Good) {
const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null)
const editVariants = computed(() => (editGood.value as GoodDetail | null)?.variants ?? [])
const editSizeColumns = computed(() => editOriginDetail.value?.sizeChart?.columns ?? [])
const editSizeRows = computed(() => {
const rows = editOriginDetail.value?.sizeChart?.rows ?? []
return rows.map((row: any) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
}))
})
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
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
editForm.value = {
id: g.id, goodName: g.goodName,
@@ -444,9 +571,119 @@ function openEdit(g: Good) {
positionId: g.positionId || '',
}
editVisible.value = true
editDetailLoading.value = true
try {
const detail = await goodsApi.getGoodById(g.id)
editGood.value = detail
if (detail.originGood?.isCustom) fillCustomContent(detail)
} catch {
ElMessage.warning('商品详情加载失败,当前显示列表数据')
} finally {
editDetailLoading.value = false
}
}
async function handleSyncOriginDetail(data: any) {
if (!data.sdsGoodId || syncingOriginGoodIds.value.has(data.sdsGoodId)) return
syncingOriginGoodIds.value = new Set(syncingOriginGoodIds.value).add(data.sdsGoodId)
try {
const result = await syncApi.syncOneProductDetail(data.sdsGoodId)
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
await refreshRightTree()
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
} finally {
const next = new Set(syncingOriginGoodIds.value)
next.delete(data.sdsGoodId)
syncingOriginGoodIds.value = next
}
}
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)
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
await Promise.all([refreshLeftTree(), refreshRightTree()])
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
} finally {
detailSyncing.value = false
}
}
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, {
@@ -457,6 +694,7 @@ async function handleEditSubmit() {
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()
@@ -466,8 +704,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) {
@@ -1200,6 +1439,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">
@@ -1279,7 +1519,7 @@ 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>
@@ -1289,13 +1529,14 @@ onMounted(() => loadAll())
</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"
@@ -1351,7 +1592,22 @@ onMounted(() => loadAll())
v-else
class="og-badge og-badge--warn"
>未配置</span>
<span
class="og-badge"
:class="data.hasDetail ? 'og-badge--detail' : 'og-badge--missing'"
:title="data.detailSyncedAt ? `详情同步于 ${new Date(data.detailSyncedAt).toLocaleString()}` : '尚未同步商品详情'"
>
{{ data.hasDetail ? `详情 · ${data.variantCount} SKU` : '缺详情' }}
</span>
<span v-if="data.goodPrice" class="og-price">¥{{ data.goodPrice }}</span>
<el-button
size="small"
link
:icon="Refresh"
:loading="syncingOriginGoodIds.has(data.sdsGoodId)"
:title="data.hasDetail ? '重新同步商品详情' : '同步商品详情'"
@click.stop="handleSyncOriginDetail(data)"
/>
<el-button
v-if="data.configuredCount > 0"
size="small" link :icon="Aim" title="定位到官网商品"
@@ -1464,7 +1720,7 @@ 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 })" />
@@ -1488,11 +1744,6 @@ onMounted(() => loadAll())
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) configForm.value.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>
@@ -1500,32 +1751,87 @@ 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="560px" destroy-on-close>
<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 ? '详情已同步' : '详情未同步' }}
</el-tag>
<span v-if="editGood.originGood.detailSyncedAt">
{{ new Date(editGood.originGood.detailSyncedAt).toLocaleString() }}
</span>
<span>SKU {{ editGood.originGood.variantCount || 0 }}</span>
<span>尺码 {{ editGood.originGood.sizeRowCount || 0 }}</span>
<span>包装 {{ editGood.originGood.packageRowCount || 0 }}</span>
</div>
</div>
<el-form label-width="80px" style="margin-top: 16px">
<el-button
v-if="!editIsCustom"
type="primary"
plain
size="small"
:icon="Refresh"
:loading="detailSyncing"
@click="handleSyncOneDetail"
>同步详情</el-button>
</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="图片">
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
</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">
@@ -1534,10 +1840,108 @@ 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-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>
<el-descriptions-item label="净重">{{ editOriginDetail.minWeightG ?? '-' }} g</el-descriptions-item>
<el-descriptions-item label="生产工艺">{{ editOriginDetail.productionProcess || '-' }}</el-descriptions-item>
<el-descriptions-item label="材质">{{ editOriginDetail.materialDescription || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
<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"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="120"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
<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 }">
{{ row.dimensionsCm ? `${row.dimensionsCm.length}×${row.dimensionsCm.width}×${row.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="120" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="150" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${editVariants.length})`">
<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" />
<el-table-column prop="price" label="价格" width="100" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '可用' : '停用' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" />
<template #footer>
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
<el-button @click="editVisible = false">取消</el-button>
@@ -1826,6 +2230,8 @@ onMounted(() => loadAll())
.og-badge--warn {
color: #ff6800; background: #fff2e8;
}
.og-badge--detail { color: #337ecc; background: #ecf5ff; }
.og-badge--missing { color: #909399; background: #f4f4f5; }
.og-config-btn {
flex-shrink: 0;
font-size: 12px;
@@ -1852,6 +2258,9 @@ onMounted(() => loadAll())
.edit-og-label { font-size: 11px; color: #909399; text-transform: uppercase; letter-spacing: 0.5px; }
.edit-og-name { font-weight: 600; font-size: 14px; margin-top: 2px; }
.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; }
.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; }
+37 -14
View File
@@ -8,7 +8,8 @@ import { syncApi } from '@/api/sync'
const logs = ref<SyncLog[]>([])
const loading = ref(false)
const syncing = ref(false)
const currentType = ref<'PRODUCTS' | 'CATEGORIES'>('PRODUCTS')
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
const currentType = ref<SyncType>('PRODUCTS')
let timer: ReturnType<typeof setInterval> | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
@@ -25,7 +26,13 @@ async function refreshLogs() {
}
}
async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
function syncTypeLabel(type: SyncType): string {
if (type === 'CATEGORIES') return '分类'
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
return '商品列表'
}
async function pollUntilDone(type: SyncType) {
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(async () => {
try {
@@ -37,9 +44,9 @@ async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
syncing.value = false
if (top.status === 'SUCCESS') {
ElMessage.success(`${type === 'PRODUCTS' ? '产品' : '分类'}同步完成`)
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
} else {
ElMessage.error(`${type === 'PRODUCTS' ? '产品' : '分类'}同步失败`)
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
}
await refreshLogs()
}
@@ -55,11 +62,15 @@ async function handleSyncCategories() {
await doSync('CATEGORIES')
}
async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
const label = type === 'PRODUCTS' ? '产品' : '分类'
async function handleSyncProductDetails() {
await doSync('PRODUCT_DETAILS')
}
async function doSync(type: SyncType) {
const label = syncTypeLabel(type)
try {
await ElMessageBox.confirm(
`确定立即执行${label}同步吗?${type === 'PRODUCTS' ? '此操作可能需要几分钟。' : ''}`,
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
'确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
)
@@ -70,6 +81,8 @@ async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
try {
if (type === 'PRODUCTS') {
await syncApi.syncProducts()
} else if (type === 'PRODUCT_DETAILS') {
await syncApi.syncProductDetails()
} else {
await syncApi.syncCategories()
}
@@ -97,7 +110,7 @@ function formatDuration(start?: string, end?: string): string | null {
const stats = computed(() => {
const total = logs.value.length
const success = logs.value.filter(l => l.status === 'SUCCESS').length
const failed = total - success
const failed = logs.value.filter(l => l.status === 'FAILED').length
const lastLog = logs.value[0]
return { total, success, failed, lastLog }
})
@@ -123,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">
@@ -145,6 +158,16 @@ onUnmounted(() => {
>
同步产品
</el-button>
<el-button
type="success"
size="large"
:loading="syncing && currentType === 'PRODUCT_DETAILS'"
:disabled="syncing"
:icon="Refresh"
@click="handleSyncProductDetails"
>
同步全部原产品详情
</el-button>
</div>
</div>
@@ -166,7 +189,7 @@ onUnmounted(() => {
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startTime) : '-' }}</span>
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
<span class="stat-label">最近同步</span>
</div>
</div>
@@ -197,17 +220,17 @@ onUnmounted(() => {
</div>
<div class="timeline-content">
<div class="timeline-header">
<span class="timeline-type">{{ log.type === 'PRODUCTS' ? '产品' : '分类' }}</span>
<span class="timeline-type">{{ syncTypeLabel(log.type) }}</span>
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
</span>
<span v-if="formatDuration(log.startTime, log.endTime)" class="timeline-duration">
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
<el-icon :size="11"><Clock /></el-icon>
{{ formatDuration(log.startTime, log.endTime) }}
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
</span>
</div>
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
<span class="timeline-time">{{ formatTime(log.startTime) }}</span>
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
</div>
</div>
</div>
+4 -3
View File
@@ -7,6 +7,7 @@ import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
base: '/admin/',
plugins: [
vue(),
AutoImport({
@@ -29,16 +30,16 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
target: 'http://127.0.0.1:3001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/uploads': {
target: 'http://localhost:3001',
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
'/assets': {
target: 'http://localhost:3001',
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
},
+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
+6 -1
View File
@@ -21,7 +21,8 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts"
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
"import:product-detail": "ts-node prisma/import-product-detail.ts"
},
"dependencies": {
"@nestjs/axios": "^3.0.1",
@@ -33,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",
@@ -51,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",
+36
View File
@@ -0,0 +1,36 @@
import { readFile } from 'fs/promises';
import { resolve } from 'path';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { SyncService } from '../src/sync/sync.service';
import { SdsProductDetail } from '../src/sync/sds-client.service';
async function main(): Promise<void> {
const inputPath = process.argv[2];
if (!inputPath) {
throw new Error('Usage: pnpm --filter @inkreach/api import:product-detail -- <product_detail.txt>');
}
const absolutePath = resolve(inputPath);
const raw = await readFile(absolutePath, 'utf8');
const jsonStart = raw.indexOf('{');
if (jsonStart < 0) throw new Error('No JSON object found in product detail file');
const detail = JSON.parse(raw.slice(jsonStart)) as SdsProductDetail;
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
try {
const result = await app.get(SyncService).importProductDetail(detail);
process.stdout.write(
`Imported SDS product ${result.goodId}: ${result.variants} variants, ` +
`${result.sizeRows} size rows, ${result.packageRows} package rows, ` +
`${result.configuredGoods} configured goods\n`,
);
} finally {
await app.close();
}
}
void main().catch((error: unknown) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
@@ -0,0 +1,70 @@
-- Cache SDS product details separately from website merchandising configuration.
CREATE TABLE "origin_good_details" (
"origin_good_id" BIGINT NOT NULL,
"product_code" TEXT,
"english_name" TEXT,
"blank_design_url" TEXT,
"details_page_video_url" TEXT,
"texture_name" TEXT,
"production_cycle_hours" INTEGER,
"min_weight_g" DECIMAL(12,3),
"reminder" TEXT,
"production_process" TEXT,
"material_description" TEXT,
"product_performance" TEXT,
"applicable_scenarios" TEXT,
"washing_instructions" TEXT,
"special_description" TEXT,
"design_explanation" TEXT,
"design_area" TEXT,
"picture_request" TEXT,
"size_chart" JSONB,
"package_specs" JSONB,
"options" JSONB,
"media" JSONB,
"upstream_updated_at" TIMESTAMPTZ(6),
"synced_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "origin_good_details_pkey" PRIMARY KEY ("origin_good_id")
);
CREATE TABLE "origin_good_variants" (
"origin_good_variant_id" BIGSERIAL NOT NULL,
"origin_good_id" BIGINT NOT NULL,
"sds_variant_id" TEXT NOT NULL,
"sku" TEXT NOT NULL,
"size_id" TEXT,
"size_name" TEXT,
"color_id" TEXT,
"color_name" TEXT,
"color_hex" TEXT,
"image_url" TEXT,
"price" DECIMAL(12,2),
"original_price" DECIMAL(12,2),
"weight_g" DECIMAL(12,3),
"box_length_cm" DECIMAL(12,3),
"box_width_cm" DECIMAL(12,3),
"box_height_cm" DECIMAL(12,3),
"enabled" BOOLEAN NOT NULL DEFAULT true,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"design_data" JSONB,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "origin_good_variants_pkey" PRIMARY KEY ("origin_good_variant_id")
);
CREATE UNIQUE INDEX "origin_good_variants_origin_good_id_sds_variant_id_key"
ON "origin_good_variants"("origin_good_id", "sds_variant_id");
CREATE INDEX "origin_good_variants_origin_good_id_sort_order_idx"
ON "origin_good_variants"("origin_good_id", "sort_order");
CREATE INDEX "origin_good_variants_sku_idx" ON "origin_good_variants"("sku");
ALTER TABLE "origin_good_details"
ADD CONSTRAINT "origin_good_details_origin_good_id_fkey"
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE "origin_good_variants"
ADD CONSTRAINT "origin_good_variants_origin_good_id_fkey"
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1 @@
ALTER TYPE "SyncType" ADD VALUE IF NOT EXISTS 'PRODUCT_DETAILS';
@@ -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;
+83
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,15 +29,84 @@ 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[]
@@index([sdsCategoryId])
@@index([source])
@@map("origin_goods")
}
// ---------- Origin Good Details (cached from SDS /products/{id}) ----------
model OriginGoodDetail {
originGoodId BigInt @id @map("origin_good_id")
productCode String? @map("product_code")
englishName String? @map("english_name")
blankDesignUrl String? @map("blank_design_url")
detailsPageVideoUrl String? @map("details_page_video_url")
textureName String? @map("texture_name")
productionCycleHours Int? @map("production_cycle_hours")
minWeightG Decimal? @map("min_weight_g") @db.Decimal(12, 3)
reminder String?
productionProcess String? @map("production_process")
materialDescription String? @map("material_description")
productPerformance String? @map("product_performance")
applicableScenarios String? @map("applicable_scenarios")
washingInstructions String? @map("washing_instructions")
specialDescription String? @map("special_description")
designExplanation String? @map("design_explanation")
designArea String? @map("design_area")
pictureRequest String? @map("picture_request")
sizeChart Json? @map("size_chart")
packageSpecs Json? @map("package_specs")
options Json?
media Json?
upstreamUpdatedAt DateTime? @map("upstream_updated_at") @db.Timestamptz(6)
syncedAt DateTime @default(now()) @map("synced_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@map("origin_good_details")
}
// ---------- Origin Good Variants (cached SDS child products / SKUs) ----------
model OriginGoodVariant {
id BigInt @id @default(autoincrement()) @map("origin_good_variant_id")
originGoodId BigInt @map("origin_good_id")
sdsVariantId String @map("sds_variant_id")
sku String
sizeId String? @map("size_id")
sizeName String? @map("size_name")
colorId String? @map("color_id")
colorName String? @map("color_name")
colorHex String? @map("color_hex")
imageUrl String? @map("image_url")
price Decimal? @db.Decimal(12, 2)
originalPrice Decimal? @map("original_price") @db.Decimal(12, 2)
weightG Decimal? @map("weight_g") @db.Decimal(12, 3)
boxLengthCm Decimal? @map("box_length_cm") @db.Decimal(12, 3)
boxWidthCm Decimal? @map("box_width_cm") @db.Decimal(12, 3)
boxHeightCm Decimal? @map("box_height_cm") @db.Decimal(12, 3)
enabled Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
designData Json? @map("design_data")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([originGoodId, sdsVariantId])
@@index([originGoodId, sortOrder])
@@index([sku])
@@map("origin_good_variants")
}
// ---------- Countries ----------
model Country {
id BigInt @id @default(autoincrement()) @map("country_id")
@@ -167,10 +242,17 @@ model GoodTag {
}
// ---------- 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)
@@ -181,6 +263,7 @@ model User {
enum SyncType {
CATEGORIES
PRODUCTS
PRODUCT_DETAILS
}
enum SyncStatus {
+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 } },
});
});
});
});
+107 -11
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');
throw new UnauthorizedException();
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
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,
+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),
) {}
+65
View File
@@ -9,9 +9,28 @@ export interface GoodRelations {
originGood?: {
id: bigint;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
goodName: string | null;
goodImage: string | null;
goodPrice: unknown;
detail?: {
productCode: string | null;
syncedAt: Date;
sizeChart: unknown;
packageSpecs: unknown;
[key: string]: unknown;
} | null;
variants?: Array<{
sdsVariantId: string;
sku: string;
sizeName: string | null;
colorName: string | null;
colorHex: string | null;
price: unknown;
enabled: boolean;
[key: string]: unknown;
}>;
_count?: { variants: number };
} | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
}
@@ -69,9 +88,17 @@ export class GoodDto {
originGood?: {
id: string;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
isCustom: boolean;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
hasDetail: boolean;
detailSyncedAt: string | null;
variantCount: number;
sizeRowCount: number;
packageRowCount: number;
productCode: string | null;
} | null;
static from(
@@ -130,6 +157,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:
@@ -137,10 +166,46 @@ export class GoodDto {
rel.originGood.goodPrice === undefined
? null
: (rel.originGood.goodPrice as { toString(): string }).toString(),
hasDetail: Boolean(rel.originGood.detail),
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
productCode: rel.originGood.detail?.productCode ?? null,
}
: null,
};
}
private static jsonRows(value: unknown): number {
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
const rows = (value as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows.length : 0;
}
}
export class GoodDetailDto extends GoodDto {
@ApiProperty({ nullable: true, type: Object })
originDetail!: Record<string, unknown> | null;
@ApiProperty({ type: Array })
variants!: Array<Record<string, unknown>>;
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
const base = GoodDto.from(good, rel);
const detail = rel.originGood?.detail;
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(),
})),
};
}
}
export interface PaginatedGoods {
+37 -18
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()
@@ -36,18 +40,45 @@ export class GoodsController {
return this.service.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get one good with relations' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Post()
@ApiOperation({ summary: 'Create a good' })
create(@Body() dto: CreateGoodDto) {
return this.service.create(dto);
}
@Patch('batch-priority')
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
batchPriority(@Body() dto: BatchPriorityDto) {
return this.service.batchUpdatePriority(dto);
}
@Post('batch')
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
batchCreate(@Body() dto: BatchCreateGoodDto) {
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) {
return this.service.findOne(BigInt(id));
}
@Patch(':id')
@ApiOperation({ summary: 'Update a good' })
update(
@@ -62,16 +93,4 @@ export class GoodsController {
remove(@Param('id', ParseIntPipe) id: string) {
return this.service.remove(BigInt(id));
}
@Patch('batch-priority')
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
batchPriority(@Body() dto: BatchPriorityDto) {
return this.service.batchUpdatePriority(dto);
}
@Post('batch')
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
batchCreate(@Body() dto: BatchCreateGoodDto) {
return this.service.batchCreate(dto);
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller';
import { GoodsService } from './goods.service';
import { SyncModule } from '../sync/sync.module';
@Module({
imports: [SyncModule],
controllers: [GoodsController],
providers: [GoodsService],
exports: [GoodsService],
+54 -1
View File
@@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import { GoodsService } from './goods.service';
import { PrismaService } from '../prisma/prisma.service';
import { SyncService } from '../sync/sync.service';
describe('GoodsService', () => {
let service: GoodsService;
@@ -22,7 +23,14 @@ describe('GoodsService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [GoodsService, PrismaService],
providers: [
GoodsService,
PrismaService,
{
provide: SyncService,
useValue: { queueProductDetailSync: jest.fn() },
},
],
}).compile();
service = moduleRef.get(GoodsService);
prisma = moduleRef.get(PrismaService);
@@ -111,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,
+262 -11
View File
@@ -10,20 +10,37 @@ 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 { GoodDto, PaginatedGoods } from './dto/good.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,
category: true,
tag: true,
position: true,
originGood: true,
originGood: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
_count: { select: { variants: true } },
},
},
goodTags: { include: { tag: true } },
} satisfies Prisma.GoodInclude;
@Injectable()
export class GoodsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly syncService: SyncService,
) {}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
@@ -65,13 +82,13 @@ export class GoodsService {
};
}
async findOne(id: bigint): Promise<GoodDto> {
async findOne(id: bigint): Promise<GoodDetailDto> {
const good = await this.prisma.good.findUnique({
where: { id },
include: GOOD_INCLUDE,
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
return GoodDto.from(good, {
return GoodDetailDto.fromGood(good, {
country: good.country,
category: good.category,
tag: good.tag,
@@ -83,7 +100,7 @@ export class GoodsService {
async create(dto: CreateGoodDto): Promise<GoodDto> {
await this.ensureReferences(dto);
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
const created = await tx.good.create({
data: {
goodName: dto.goodName,
@@ -116,6 +133,115 @@ export class GoodsService {
goodTags: result.goodTags,
});
});
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> {
@@ -148,7 +274,7 @@ export class GoodsService {
}
}
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
if (dto.tagIds !== undefined) {
await tx.goodTag.deleteMany({ where: { goodId: id } });
if (dto.tagIds.length > 0) {
@@ -174,11 +300,33 @@ export class GoodsService {
goodTags: updated.goodTags,
});
});
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() };
}
@@ -187,7 +335,7 @@ export class GoodsService {
* or none do.
*/
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
for (const item of dto.items) {
await tx.good.update({
where: { id: BigInt(item.id) },
@@ -196,6 +344,7 @@ export class GoodsService {
}
return { count: dto.items.length };
});
return result;
}
/**
@@ -209,7 +358,7 @@ export class GoodsService {
await this.ensureTag(tagId);
}
}
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
const created: GoodDto[] = [];
for (const item of dto.items) {
const og = await tx.originGood.findUnique({
@@ -254,6 +403,19 @@ export class GoodsService {
}
return created;
});
for (const goodId of new Set(
result
.filter(
(item) =>
item.originGood?.source === 'SDS' &&
!item.originGood.hasDetail,
)
.map((item) => item.originGood?.sdsGoodId)
.filter((id): id is string => Boolean(id)),
)) {
this.syncService.queueProductDetailSync(goodId);
}
return result;
}
/**
@@ -313,4 +475,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),
},
});
}
}
}
+36 -8
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,7 +81,9 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
// Swagger
// Swagger is only exposed outside production to avoid leaking the
// full admin API surface.
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('InkReach Product Center API')
.setDescription('Backend API for InkReach Product Center')
@@ -65,11 +93,11 @@ async function bootstrap() {
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
@@ -13,6 +13,9 @@ export interface PaginatedOriginGoods {
sdsCategoryId: string | null;
createdAt: string;
updatedAt: string;
hasDetail: boolean;
detailSyncedAt: string | null;
variantCount: number;
}>;
total: number;
page: number;
@@ -33,6 +36,11 @@ export interface OriginGoodsTreeNode {
configuredCount: number;
configuredCountries: string[];
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
hasDetail: boolean;
detailSyncedAt: string | null;
variantCount: number;
sizeRowCount: number;
packageRowCount: number;
}
/** A category node in the hierarchical tree, with origin goods as leaves. */
@@ -59,15 +67,19 @@ 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 }),
this.prisma.originGood.findMany({
where,
orderBy: { id: 'desc' },
include: { detail: true, _count: { select: { variants: true } } },
skip: (page - 1) * pageSize,
take: pageSize,
}),
@@ -83,6 +95,9 @@ export class OriginGoodsService {
sdsCategoryId: r.sdsCategoryId,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
hasDetail: Boolean(r.detail),
detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null,
variantCount: r._count.variants,
})),
total,
page,
@@ -111,7 +126,11 @@ export class OriginGoodsService {
parentCategoryId: true,
},
}),
this.prisma.originGood.findMany({ where: { delisted: false }, orderBy: { goodName: 'asc' } }),
this.prisma.originGood.findMany({
where: { delisted: false, source: 'SDS' },
orderBy: { goodName: 'asc' },
include: { detail: true, _count: { select: { variants: true } } },
}),
this.prisma.good.groupBy({
by: ['originGoodId'],
_count: { _all: true },
@@ -212,6 +231,11 @@ export class OriginGoodsService {
configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [],
hasDetail: Boolean(og.detail),
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
variantCount: og._count.variants,
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
}));
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
@@ -258,6 +282,11 @@ export class OriginGoodsService {
configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [],
hasDetail: Boolean(og.detail),
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
variantCount: og._count.variants,
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
})),
});
}
@@ -274,4 +303,10 @@ export class OriginGoodsService {
configuredCount: totalConfigured,
};
}
private jsonRows(value: unknown): number {
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
const rows = (value as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows.length : 0;
}
}
@@ -17,6 +17,9 @@ export class PublicCategoryNodeDto {
@ApiProperty({ type: [PublicCategoryNodeDto] })
children!: PublicCategoryNodeDto[];
@ApiProperty({ description: '当前节点及其后代分类的商品数量' })
productCount!: number;
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
return {
id: category.id.toString(),
@@ -26,6 +29,7 @@ export class PublicCategoryNodeDto {
? category.parentCategoryId.toString()
: null,
children,
productCount: 0,
};
}
}
@@ -0,0 +1,89 @@
import { ApiProperty } from '@nestjs/swagger';
import { PublicGoodDto } from './public-good.dto';
export class PublicGoodDetailDto extends PublicGoodDto {
@ApiProperty({ nullable: true })
productCode!: string | null;
@ApiProperty({ nullable: true })
englishName!: string | null;
@ApiProperty({ nullable: true })
productionCycleHours!: number | null;
@ApiProperty({ nullable: true })
minWeightG!: string | null;
@ApiProperty({ type: Object })
details!: Record<string, string | null>;
@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;
@ApiProperty({ nullable: true, type: Object })
sizeChart!: Record<string, unknown> | null;
@ApiProperty({ nullable: true, type: Object })
packageSpecs!: Record<string, unknown> | null;
@ApiProperty({ type: Array })
variants!: Array<{
id: string;
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;
enabled: boolean;
sortOrder: number;
}>;
@ApiProperty({ nullable: true })
detailSyncedAt!: string | null;
}
export class PublicTagGroupFilterDto {
@ApiProperty()
id!: string;
@ApiProperty()
groupName!: string;
@ApiProperty({ nullable: true })
groupIcon!: string | null;
@ApiProperty({ nullable: true })
groupColor!: string | null;
@ApiProperty()
sortOrder!: number;
@ApiProperty({ type: Array })
tags!: Array<{
id: string;
tagName: string;
tagColor: string | null;
tagFontColor: string | null;
sortOrder: number;
productCount: number;
}>;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
export class PublicGoodDto {
@ApiProperty()
id!: string;
goodId!: string;
@ApiProperty()
goodName!: string;
@@ -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,13 +1,53 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
import { plainToInstance, Transform, Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsNumberString,
IsOptional,
IsString,
ValidateNested,
ArrayNotEmpty,
Max,
Min,
} from 'class-validator';
const stringList = ({ value }: { value: unknown }): string[] | undefined => {
if (value === undefined || value === null || value === '') return undefined;
const values = Array.isArray(value) ? value : [value];
return values
.flatMap((item) => String(item).split(','))
.map((item) => item.trim())
.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()
@@ -16,7 +56,7 @@ export class PublicQueryGoodDto {
@Min(1)
page: number = 1;
@ApiProperty({ required: false, default: 20 })
@ApiProperty({ required: false, default: 20, maximum: 200 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -24,25 +64,57 @@ export class PublicQueryGoodDto {
@Max(200)
pageSize: number = 20;
@ApiProperty({ required: false })
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
@IsOptional()
@Type(() => Number)
@IsInt()
countryId?: number;
@IsNumberString()
countryId?: string;
@ApiProperty({ required: false, type: String })
@IsOptional()
@IsNumberString()
categoryId?: string;
@ApiHideProperty()
@IsOptional()
@Transform(tagFilters)
@IsArray()
@ValidateNested({ each: true })
tags?: PublicTagFilterDto[];
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
categoryId?: number;
@ApiProperty({ required: false, description: 'Comma-separated tag IDs, e.g. "30,34"' })
@IsOptional()
@IsString()
tagIds?: string;
minPrice?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
maxPrice?: string;
@ApiProperty({ required: false, enum: ['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'] })
@IsOptional()
@IsIn(['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'])
sort?: 'DEFAULT' | 'PRICE_ASC' | 'PRICE_DESC' | 'NEWEST';
@ApiProperty({ required: false })
@IsOptional()
@IsString()
keyword?: string;
}
export class PublicCountryQueryDto {
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
@IsOptional()
@IsNumberString()
countryId?: string;
}
export class PublicHomeGoodsQueryDto extends PublicCountryQueryDto {
@ApiProperty({ required: false, default: 10, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit: number = 10;
}
+57 -15
View File
@@ -2,24 +2,38 @@ import {
Controller,
Get,
Param,
ParseIntPipe,
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import { PublicService } from './public.service';
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
import {
PublicCountryQueryDto,
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto';
import { PublicTagDto } from './dto/public-tag.dto';
import { PublicTagGroupDto } from './dto/public-tag-group.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) {}
@Get('categories')
@ApiOperation({ summary: 'Public list of categories that have goods' })
getCategories() {
return this.service.getCategoriesTree();
@ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
getCategories(@Query() query: PublicCountryQueryDto) {
return this.service.getCategoriesTree(query.countryId);
}
@Get('countries')
@@ -35,20 +49,48 @@ export class PublicController {
}
@Get('tag-groups')
@ApiOperation({ summary: 'Public list of tag groups that have goods' })
getTagGroups(): Promise<PublicTagGroupDto[]> {
return this.service.getTagGroups();
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
return this.service.getTagGroups(query.countryId);
}
@Get('goods')
@ApiOperation({ summary: 'Public paginated goods with filters' })
@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);
}
@Get('goods/:id')
@ApiOperation({ summary: 'Public good detail' })
getGood(@Param('id', ParseIntPipe) id: string) {
return this.service.getGood(BigInt(id));
@Get('goods/:goodId')
@ApiOperation({ summary: '获取商品完整详情' })
@ApiParam({ name: 'goodId', type: String, example: '168746' })
@ApiOkResponse({ type: PublicGoodDetailDto })
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
return this.service.getGood(goodId);
}
@Get('home-goods')
@ApiOperation({ summary: '获取首页商品;可按国家返回' })
@ApiOkResponse({ type: [PublicGoodDto] })
getHomeGoods(@Query() query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
return this.service.getHomeGoods(query);
}
}
+146 -11
View File
@@ -1,5 +1,5 @@
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -12,6 +12,8 @@ describe('PublicService', () => {
let childCategoryId: bigint;
let otherCategoryId: bigint;
let tagId: bigint;
let filterGroupIds: bigint[] = [];
let filterTagIds: bigint[] = [];
let originGoodId: bigint;
let goodIds: bigint[] = [];
@@ -98,6 +100,50 @@ describe('PublicService', () => {
});
goodIds = [g1.id, g2.id, g3.id];
const craftGroup = await prisma.tagGroup.create({
data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 },
});
const materialGroup = await prisma.tagGroup.create({
data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 },
});
filterGroupIds = [craftGroup.id, materialGroup.id];
const craftA = await prisma.tag.create({
data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id },
});
const craftB = await prisma.tag.create({
data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id },
});
const cotton = await prisma.tag.create({
data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id },
});
filterTagIds = [craftA.id, craftB.id, cotton.id];
await prisma.goodTag.createMany({
data: [
{ goodId: g1.id, tagId: craftA.id },
{ goodId: g1.id, tagId: cotton.id },
{ goodId: g2.id, tagId: craftB.id },
],
});
await prisma.originGoodDetail.create({
data: {
originGoodId,
productCode: 'OZ10827003',
productionProcess: '白墨烫画',
sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] },
packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] },
},
});
await prisma.originGoodVariant.create({
data: {
originGoodId,
sdsVariantId: `pub-variant-${stamp}`,
sku: `OZ${stamp}`,
sizeName: 'S',
price: 38,
},
});
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
// returns more than one category.
await prisma.good.create({
@@ -134,6 +180,8 @@ describe('PublicService', () => {
where: { countryId },
});
await prisma.tag.delete({ where: { id: tagId } });
await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } });
await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } });
await prisma.originGood.delete({ where: { id: originGoodId } });
// Delete children before parent (FK self-relation is RESTRICT).
await prisma.category.delete({ where: { id: childCategoryId } });
@@ -172,8 +220,8 @@ describe('PublicService', () => {
const filtered = await service.getGoods({
page: 1,
pageSize: 50,
countryId: Number(countryId),
categoryId: Number(categoryId), // includes child
countryId: countryId.toString(),
categoryId: categoryId.toString(), // includes child
keyword: `Pub `,
});
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
@@ -184,7 +232,7 @@ describe('PublicService', () => {
const result = await service.getGoods({
page: 1,
pageSize: 50,
countryId: Number(countryId),
countryId: countryId.toString(),
keyword: `Pub `,
});
const priorities = result.items.map((g) => g.goodPriority);
@@ -193,31 +241,118 @@ describe('PublicService', () => {
expect(priorities).toEqual(sorted);
});
it('uses OR within one tag group and AND across tag groups', async () => {
const sameGroup = await service.getGoods({
page: 1,
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
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}`]),
);
const acrossGroups = await service.getGoods({
page: 1,
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
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}`);
});
it('rejects a tag paired with the wrong tag group', async () => {
await expect(
service.getGoods({
page: 1,
pageSize: 20,
tags: [
{
tagGroupId: filterGroupIds[1].toString(),
tagIds: [filterTagIds[0].toString()],
},
],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('returns the SDS product id as the public product id', async () => {
const result = await service.getGoods({
page: 1,
pageSize: 1,
countryId: Number(countryId),
countryId: countryId.toString(),
keyword: `Pub High ${stamp}`,
});
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe(`pub-sds-${stamp}`);
expect(result.items[0].id).not.toBe(goodIds[0].toString());
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
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,
pageSize: 1,
countryId: Number(countryId),
countryId: countryId.toString(),
keyword: `Pub `,
});
expect(first.items.length).toBe(1);
const detail = await service.getGood(goodIds[0]);
expect(detail.id).toBe(first.items[0].id);
const detail = await service.getGood(`pub-sds-${stamp}`);
expect(detail.goodId).toBe(first.items[0].goodId);
expect(detail.productCode).toBe('OZ10827003');
expect(detail.details.productionProcess).toBe('白墨烫画');
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
expect(detail.variants).toHaveLength(1);
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf(
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
NotFoundException,
);
});
+325 -98
View File
@@ -1,12 +1,20 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
import {
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto';
import { PublicCategoryNodeDto } from './dto/public-category.dto';
import { PublicCountryDto } from './dto/public-country.dto';
import { PublicTagDto } from './dto/public-tag.dto';
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
import { PublicGoodDto } from './dto/public-good.dto';
import {
PublicGoodDetailDto,
PublicTagGroupFilterDto,
} from './dto/public-good-detail.dto';
export interface PublicPaginatedGoods {
items: PublicGoodDto[];
@@ -20,17 +28,28 @@ const PUBLIC_GOOD_INCLUDE = {
category: true,
tag: { include: { tagGroup: true } },
position: true,
originGood: true,
originGood: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
},
},
goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude;
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
async getCategoriesTree(): Promise<PublicCategoryNodeDto[]> {
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
const goodsWhere: Prisma.GoodWhereInput = {
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
const leafCategories = await this.prisma.category.findMany({
where: { goods: { some: {} } },
where: { goods: { some: goodsWhere } },
orderBy: { id: 'asc' },
});
const ancestorIds = new Set<bigint>();
@@ -46,22 +65,30 @@ export class PublicService {
cursor = parent.parentCategoryId;
}
}
const ancestorRows = ancestorIds.size > 0
const ancestorRows = ancestorIds.size
? await this.prisma.category.findMany({
where: { id: { in: [...ancestorIds] } },
orderBy: { id: 'asc' },
})
: [];
const allRows = [...leafCategories, ...ancestorRows].filter(
(row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx,
(row, index, rows) => rows.findIndex((item) => item.id === row.id) === index,
);
allRows.sort((a, b) => Number(a.id - b.id));
return this.buildTree(allRows);
const directCounts = await this.prisma.good.groupBy({
by: ['categoryId'],
where: goodsWhere,
_count: { _all: true },
});
return this.buildTree(
allRows,
new Map(directCounts.map((row) => [row.categoryId, row._count._all])),
);
}
async getCountries(): Promise<PublicCountryDto[]> {
const rows = await this.prisma.country.findMany({
where: { goods: { some: {} } },
where: { goods: { some: { originGood: { delisted: false } } } },
orderBy: { id: 'asc' },
});
return rows.map(PublicCountryDto.from);
@@ -69,7 +96,7 @@ export class PublicService {
async getTags(): Promise<PublicTagDto[]> {
const rows = await this.prisma.tag.findMany({
where: { goodTags: { some: {} } },
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
orderBy: [
{ tagGroup: { sortOrder: 'asc' } },
{ sortOrder: 'asc' },
@@ -80,99 +107,134 @@ export class PublicService {
return rows.map(PublicTagDto.from);
}
async getTagGroups(): Promise<PublicTagGroupDto[]> {
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
const goodWhere: Prisma.GoodWhereInput = {
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
const rows = await this.prisma.tagGroup.findMany({
where: { tags: { some: { goodTags: { some: {} } } } },
where: { tags: { some: { goodTags: { some: { good: goodWhere } } } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
include: {
tags: {
where: { goodTags: { some: { good: goodWhere } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
include: {
_count: { select: { goodTags: { where: { good: goodWhere } } } },
},
},
},
});
return rows.map(PublicTagGroupDto.from);
return rows.map((group) => ({
...PublicTagGroupDto.from(group),
tags: group.tags.map((tag) => ({
id: tag.id.toString(),
tagName: tag.tagName,
tagColor: tag.tagColor,
tagFontColor: tag.tagFontColor,
sortOrder: tag.sortOrder,
productCount: tag._count.goodTags,
})),
}));
}
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
const where: Prisma.GoodWhereInput = {
originGood: { delisted: false },
const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
if (query.countryId) where.countryId = BigInt(query.countryId);
if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
if (query.categoryId) {
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
}
const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
if (tagFilters.length) where.AND = tagFilters;
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
const maxPrice = this.parsePrice(query.maxPrice, 'maxPrice');
if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
throw new BadRequestException('minPrice 不能大于 maxPrice');
}
if (minPrice !== null || maxPrice !== null) {
where.originGood = {
delisted: false,
goodPrice: {
...(minPrice !== null ? { gte: minPrice } : {}),
...(maxPrice !== null ? { lte: maxPrice } : {}),
},
};
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
if (query.tagIds) {
const ids = query.tagIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => BigInt(s));
if (ids.length > 0) {
// AND logic: 商品必须同时具备所有选中的 tag
where.AND = ids.map((id) => ({ goodTags: { some: { tagId: id } } }));
}
}
if (query.keyword) {
where.goodName = { contains: query.keyword, mode: 'insensitive' };
}
if (query.categoryId !== undefined) {
const ids = await this.collectCategoryDescendants(BigInt(query.categoryId));
where.categoryId = { in: ids };
}
const orderBy: Prisma.GoodOrderByWithRelationInput[] =
query.sort === 'PRICE_ASC'
? [{ originGood: { goodPrice: 'asc' } }, { id: 'asc' }]
: query.sort === 'PRICE_DESC'
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
: query.sort === 'NEWEST'
? [{ createdAt: 'desc' }, { id: 'asc' }]
: [
{ goodPriority: 'desc' },
{ position: { indexVal: 'asc' } },
{ createdAt: 'desc' },
{ id: 'asc' },
];
const [total, rows] = await this.prisma.$transaction([
this.prisma.good.count({ where }),
this.prisma.good.findMany({
where,
include: PUBLIC_GOOD_INCLUDE,
// Server-side primary sort; PublicGoodDto retains original indexes
// for stable pagination but the final ORDER BY is mirrored below.
orderBy: [
{ goodPriority: 'desc' },
{ position: { indexVal: 'asc' } },
{ createdAt: 'desc' },
],
orderBy,
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
return {
items: rows.map((g) => this.toPublicGood(g)),
items: rows.map((good) => this.toPublicGood(good)),
total,
page: query.page,
pageSize: query.pageSize,
};
}
async getGood(id: bigint): Promise<PublicGoodDto> {
const good = await this.prisma.good.findUnique({
where: { id },
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
const good = await this.prisma.good.findFirst({
where: { originGood: { sdsGoodId: goodId, delisted: false } },
include: PUBLIC_GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
return this.toPublicGood(good);
if (!good) {
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
}
const dto = this.toPublicGoodDetail(good);
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
return dto;
}
private toPublicGood(good: {
id: bigint;
goodName: string;
goodImage: string | null;
goodPriority: number;
country: { id: bigint; countryName: string; countryIcon: string | null };
category: { id: bigint; categoryName: string; categoryIcon: string | null };
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null;
position: { id: bigint; indexVal: number } | null;
originGood: {
sdsGoodId: string;
goodImage: string | null;
goodPrice: { toString(): string } | null;
};
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[];
createdAt: Date;
}): PublicGoodDto {
const formatGroup = (g: { id: bigint; groupName: string; sortOrder: number } | null) =>
g
? {
id: g.id.toString(),
groupName: g.groupName,
sortOrder: g.sortOrder,
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
const rows = await this.prisma.good.findMany({
where: {
positionId: { not: null },
originGood: { delisted: false },
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
},
include: PUBLIC_GOOD_INCLUDE,
orderBy: [
{ position: { indexVal: 'asc' } },
{ goodPriority: 'desc' },
{ id: 'asc' },
],
take: query.limit,
});
return rows.map((good) => this.toPublicGood(good));
}
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
group
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
: null;
return {
id: good.originGood.sdsGoodId,
goodId: good.originGood.sdsGoodId,
goodName: good.goodName,
goodPriority: good.goodPriority,
country: {
@@ -194,63 +256,228 @@ export class PublicService {
group: formatGroup(good.tag.tagGroup),
}
: null,
tags: good.goodTags.map((gt) => ({
id: gt.tag.id.toString(),
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
tagFontColor: gt.tag.tagFontColor,
group: formatGroup(gt.tag.tagGroup),
tags: good.goodTags.map(({ tag }) => ({
id: tag.id.toString(),
tagName: tag.tagName,
tagColor: tag.tagColor,
tagFontColor: tag.tagFontColor,
group: formatGroup(tag.tagGroup),
})),
position: good.position
? {
id: good.position.id.toString(),
indexVal: good.position.indexVal,
}
? { id: good.position.id.toString(), indexVal: good.position.indexVal }
: null,
image: good.goodImage ?? good.originGood?.goodImage ?? null,
price:
good.originGood?.goodPrice === null ||
good.originGood?.goodPrice === undefined
? null
: good.originGood.goodPrice.toString(),
image: good.goodImage ?? good.originGood.goodImage,
price: good.originGood.goodPrice?.toString() ?? null,
createdAt: good.createdAt.toISOString(),
};
}
/** 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: PublicGoodRow['originGood']['variants'],
): 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;
return {
...base,
productCode: detail?.productCode ?? null,
englishName: detail?.englishName ?? null,
productionCycleHours: detail?.productionCycleHours ?? null,
minWeightG: detail?.minWeightG?.toString() ?? null,
details: {
reminder: detail?.reminder ?? null,
productionProcess: detail?.productionProcess ?? null,
materialDescription: detail?.materialDescription ?? null,
productPerformance: detail?.productPerformance ?? null,
applicableScenarios: detail?.applicableScenarios ?? null,
washingInstructions: detail?.washingInstructions ?? null,
specialDescription: detail?.specialDescription ?? null,
designExplanation: detail?.designExplanation ?? null,
designArea: detail?.designArea ?? null,
pictureRequest: detail?.pictureRequest ?? null,
},
media: (detail?.media as Record<string, unknown> | null) ?? null,
mediaByColor: this.groupImagesByColor(good.originGood.variants),
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) => ({
id: variant.sdsVariantId,
sku: variant.sku,
sizeId: variant.sizeId,
sizeName: variant.sizeName,
colorId: variant.colorId,
colorName: variant.colorName,
colorHex: variant.colorHex,
imageUrl: variant.imageUrl,
price: variant.price?.toString() ?? null,
originalPrice: variant.originalPrice?.toString() ?? null,
weightG: variant.weightG?.toString() ?? null,
boxLengthCm: variant.boxLengthCm?.toString() ?? null,
boxWidthCm: variant.boxWidthCm?.toString() ?? null,
boxHeightCm: variant.boxHeightCm?.toString() ?? null,
enabled: variant.enabled,
sortOrder: variant.sortOrder,
})),
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
};
}
private async buildTagGroupFilters(
selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> {
const selections = selectedGroups.flatMap((group) => {
if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
throw new BadRequestException(
'tags 每个元素必须包含合法的 tagGroupId 和 tagIds',
);
}
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
? await this.prisma.tag.findMany({
where: { id: { in: uniqueTagIds.map((id) => BigInt(id)) } },
select: { id: true, tagGroupId: true },
})
: [];
if (selected.length !== uniqueTagIds.length) {
throw new BadRequestException('包含不存在的标签 ID');
}
const actualGroups = new Map(
selected.map((tag) => [
tag.id.toString(),
tag.tagGroupId?.toString() ?? null,
]),
);
for (const selection of selections) {
if (actualGroups.get(selection.tagId) !== selection.tagGroupId) {
throw new BadRequestException(
`标签 ${selection.tagId} 不属于标签组 ${selection.tagGroupId}`,
);
}
}
const byGroup = new Map<string, bigint[]>();
for (const selection of selections) {
const key = selection.tagGroupId;
const ids = byGroup.get(key) ?? [];
const id = BigInt(selection.tagId);
if (!ids.includes(id)) ids.push(id);
byGroup.set(key, ids);
}
return [...byGroup.values()].map((ids) => ({
goodTags: { some: { tagId: { in: ids } } },
}));
}
private parsePrice(value: string | undefined, field: string): number | null {
if (value === undefined || value === '') return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new BadRequestException(`${field} 必须是大于等于 0 的金额`);
}
return parsed;
}
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
const ids: bigint[] = [rootId];
let frontier: bigint[] = [rootId];
while (frontier.length > 0) {
while (frontier.length) {
const children = await this.prisma.category.findMany({
where: { parentCategoryId: { in: frontier } },
select: { id: true },
});
if (children.length === 0) break;
const childIds = children.map((c) => c.id);
ids.push(...childIds);
frontier = childIds;
if (!children.length) break;
frontier = children.map((child) => child.id);
ids.push(...frontier);
}
return ids;
}
private buildTree(
rows: PrismaCategory[],
directCounts: Map<bigint, number>,
): PublicCategoryNodeDto[] {
const byId = new Map<bigint, PublicCategoryNodeDto>();
for (const row of rows) {
byId.set(row.id, PublicCategoryNodeDto.from(row, []));
const node = PublicCategoryNodeDto.from(row, []);
node.productCount = directCounts.get(row.id) ?? 0;
byId.set(row.id, node);
}
const roots: PublicCategoryNodeDto[] = [];
for (const row of rows) {
const node = byId.get(row.id)!;
if (row.parentCategoryId === null) {
roots.push(node);
} else {
const parent = byId.get(row.parentCategoryId);
const parent = row.parentCategoryId === null ? null : byId.get(row.parentCategoryId);
if (parent) parent.children.push(node);
else roots.push(node);
}
}
const total = (node: PublicCategoryNodeDto): number => {
node.productCount += node.children.reduce((sum, child) => sum + total(child), 0);
return node.productCount;
};
roots.forEach(total);
return roots;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export class SyncLogDto {
id!: string;
@ApiProperty()
type!: 'CATEGORIES' | 'PRODUCTS';
type!: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS';
@ApiProperty()
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
@@ -33,4 +33,23 @@ describe('SdsClientService', () => {
expect(result).toHaveLength(20);
});
});
describe('fetchProductDetail', () => {
it('requests /products/{goodId} and validates the returned id', async () => {
http.get.mockReturnValue(of({ data: { id: 168746, sku: 'OZ10827003' } }));
const result = await service.fetchProductDetail('168746');
expect(result.sku).toBe('OZ10827003');
expect(http.get).toHaveBeenCalledWith(
'https://mapi.sdspod.com/products/168746',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('rejects a mismatched product response', async () => {
http.get.mockReturnValue(of({ data: { id: 1 } }));
await expect(service.fetchProductDetail('168746')).rejects.toThrow(/id mismatch/i);
});
});
});
+93
View File
@@ -52,6 +52,86 @@ export interface SdsProductsPage {
[key: string]: unknown;
}
export interface SdsProductVariant extends Record<string, unknown> {
id?: number | string;
sku?: string;
size?: string;
sizeId?: number | string;
sizeDto?: { id?: number | string; sizeName?: string };
colorId?: number | string;
color_name?: string;
color?: {
colorId?: number | string;
color?: string;
color_name?: string;
chineseName?: string;
};
currentPrice?: number | string;
originalPrice?: number | string;
unit_price?: number | string;
min_price?: number | string;
weight?: number | string;
box_length?: number | string;
box_width?: number | string;
box_height?: number | string;
status?: number | string;
delFlag?: number | string;
size_sort?: number | string;
attribute_sort?: string;
psd_img_url?: string;
img_url?: string;
blankDesignUrl?: string;
designPrototype?: {
detailImgUrls?: Array<{ imageUrl?: string }>;
prototypeResultGroups?: Array<{ resultImage?: string }>;
[key: string]: unknown;
};
}
export interface SdsProductDetail extends Record<string, unknown> {
id: number | string;
name?: string;
sku?: string;
english_name?: string;
blankDesignUrl?: string;
detailsPageVideoUrl?: string;
productionCycle?: number | string;
minWeight?: number | string;
min_price?: number | string;
updateTime?: number | string;
psd_img_url?: string;
img_url?: string;
texture?: { name?: string };
product_details?: {
reminder?: string;
production_process?: string;
material_description?: string;
product_performance?: string;
applicable_scenarios?: string;
washing_instructions?: string;
special_description?: string;
design_explanation?: string;
design_area?: string;
picture_request?: string;
product_size?: string;
packaging_specification?: string;
};
subproducts?: {
attributers?: Array<{
size?: string;
sizeId?: number | string;
colors?: Array<{
colorId?: number | string;
color?: string;
color_name?: string;
chineseName?: string;
colorSort?: number | string;
}>;
}>;
items?: SdsProductVariant[];
};
}
/**
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
* `SyncService` consumes.
@@ -137,4 +217,17 @@ export class SdsClientService {
}
return data as SdsProductsPage;
}
async fetchProductDetail(goodId: string | number): Promise<SdsProductDetail> {
const url = `${this.baseUrl}/products/${encodeURIComponent(String(goodId))}`;
const data = await this.request<unknown>('get', url);
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error(`SDS product detail returned ${typeof data}, expected object`);
}
const detail = data as SdsProductDetail;
if (String(detail.id) !== String(goodId)) {
throw new Error(`SDS product detail id mismatch: expected ${goodId}, got ${String(detail.id)}`);
}
return detail;
}
}
@@ -0,0 +1,86 @@
import {
normalizeProductDetail,
parsePackageSpecs,
parseSizeChart,
} from './sds-product-detail.mapper';
const sizeTable = JSON.stringify([
['尺码', '衣长(cm/in)', '胸围(cm/in)', '肩宽(cm/in)', '袖长(cm/in)'].map((content) => ({ content, remark: '' })),
['S', '71', '92', '43', '22'].map((content) => ({ content, remark: '' })),
['M', '74', '102', '45', '22'].map((content) => ({ content, remark: '' })),
['L', '76', '112', '48', '23'].map((content) => ({ content, remark: '' })),
['XL', '79', '122', '51', '23'].map((content) => ({ content, remark: '' })),
['2XL', '82', '132', '53', '25'].map((content) => ({ content, remark: '' })),
['3XL', '84', '142', '56', '25'].map((content) => ({ content, remark: '' })),
]);
const packageTable = JSON.stringify([
['尺码', '包装尺寸(cm', '包装尺寸(in', '包装体积(cm³)', '包装体积(in³)', '含包装重量(g', '含包装重量(lb'].map((content) => ({ content })),
['S', '36.0*26.0*1.0\t', '14.17*10.24*0.39\t', '936.00', '57.12', '208.00', '0.46'].map((content) => ({ content })),
['M', '36.0*26.0*1.0', '14.17*10.24*0.39', '936.00', '57.12', '218.00', '0.48'].map((content) => ({ content })),
]);
describe('SDS product detail mapper', () => {
it('parses the product_detail.txt size table into structured rows', () => {
const chart = parseSizeChart(sizeTable) as any;
expect(chart.columns.map((column: any) => column.key)).toEqual([
'bodyLength',
'chest',
'shoulder',
'sleeveLength',
]);
expect(chart.rows).toHaveLength(6);
expect(chart.rows[0].measurements[0]).toEqual({ key: 'bodyLength', cm: '71', in: '27.95' });
});
it('parses packaging dimensions and weights', () => {
const specs = parsePackageSpecs(packageTable) as any;
expect(specs.rows).toHaveLength(2);
expect(specs.rows[0].dimensionsCm).toEqual({ length: '36.0', width: '26.0', height: '1.0' });
expect(specs.rows[0].grossWeightG).toBe('208.00');
});
it('normalizes detail text, options and variants', () => {
const normalized = normalizeProductDetail({
id: 168746,
sku: 'OZ10827003',
english_name: 't-shirt',
productionCycle: 24,
minWeight: 250,
product_details: {
production_process: '白墨烫画',
material_description: '100%纯棉',
product_size: sizeTable,
packaging_specification: packageTable,
},
subproducts: {
attributers: [{
size: 'S',
sizeId: 1922304,
colors: [{ colorId: 1139383, color: '#000300', color_name: 'black', colorSort: 1 }],
}],
items: [{
id: 168747,
sku: 'OZ10827003001',
size: 'S',
sizeId: 1922304,
colorId: 1139383,
color: { colorId: 1139383, color: '#000300', color_name: 'black' },
currentPrice: 38,
originalPrice: 38,
weight: 250,
box_length: 30,
box_width: 20,
box_height: 5,
status: 1,
delFlag: '0',
}],
},
});
expect(normalized.productCode).toBe('OZ10827003');
expect(normalized.productionProcess).toBe('白墨烫画');
expect(normalized.variants[0].sku).toBe('OZ10827003001');
expect(normalized.variants[0].price?.toString()).toBe('38');
});
});
@@ -0,0 +1,261 @@
import { Prisma } from '@prisma/client';
import { SdsProductDetail, SdsProductVariant } from './sds-client.service';
type TableCell = { content?: unknown; remark?: unknown };
type Table = TableCell[][];
export interface NormalizedVariant {
sdsVariantId: string;
sku: string;
sizeId: string | null;
sizeName: string | null;
colorId: string | null;
colorName: string | null;
colorHex: string | null;
imageUrl: string | null;
price: Prisma.Decimal | null;
originalPrice: Prisma.Decimal | null;
weightG: Prisma.Decimal | null;
boxLengthCm: Prisma.Decimal | null;
boxWidthCm: Prisma.Decimal | null;
boxHeightCm: Prisma.Decimal | null;
enabled: boolean;
sortOrder: number;
designData: Prisma.InputJsonValue | null;
}
export interface NormalizedProductDetail {
productCode: string | null;
englishName: string | null;
blankDesignUrl: string | null;
detailsPageVideoUrl: string | null;
textureName: string | null;
productionCycleHours: number | null;
minWeightG: Prisma.Decimal | null;
reminder: string | null;
productionProcess: string | null;
materialDescription: string | null;
productPerformance: string | null;
applicableScenarios: string | null;
washingInstructions: string | null;
specialDescription: string | null;
designExplanation: string | null;
designArea: string | null;
pictureRequest: string | null;
sizeChart: Prisma.InputJsonValue | null;
packageSpecs: Prisma.InputJsonValue | null;
options: Prisma.InputJsonValue | null;
media: Prisma.InputJsonValue | null;
upstreamUpdatedAt: Date | null;
variants: NormalizedVariant[];
}
const text = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
};
const decimal = (value: unknown): Prisma.Decimal | null => {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isFinite(n) ? new Prisma.Decimal(n) : null;
};
const integer = (value: unknown): number | null => {
const n = Number(value);
return Number.isInteger(n) ? n : null;
};
function parseTable(raw: unknown): Table | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed) || parsed.length < 2) return null;
const rows = parsed.filter(Array.isArray) as Table;
return rows.length >= 2 ? rows : null;
} catch {
return null;
}
}
const cell = (row: TableCell[], index: number): string =>
text(row[index]?.content)?.replace(/\t/g, '').trim() ?? '';
const measurementKey = (header: string, index: number): string => {
if (header.includes('衣长')) return 'bodyLength';
if (header.includes('胸围')) return 'chest';
if (header.includes('肩宽')) return 'shoulder';
if (header.includes('袖长')) return 'sleeveLength';
return `measurement${index}`;
};
export function parseSizeChart(raw: unknown): Prisma.InputJsonValue | null {
const table = parseTable(raw);
if (!table) return null;
const [header, ...body] = table;
const columns = header.slice(1).map((item, index) => {
const name = text(item.content)?.replace(/\s*\(cm\/in\)\s*/i, '') ?? `规格${index + 1}`;
return { key: measurementKey(name, index + 1), name };
});
const rows = body
.map((row, rowIndex) => {
const sizeName = cell(row, 0);
if (!sizeName) return null;
return {
sizeId: `size_${rowIndex}`,
sizeName,
measurements: columns.map((column, index) => {
const cm = cell(row, index + 1);
const cmNumber = Number(cm);
return {
key: column.key,
cm: cm || null,
in: Number.isFinite(cmNumber) ? (cmNumber / 2.54).toFixed(2) : null,
};
}),
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
return { columns, rows } as Prisma.InputJsonValue;
}
function dimensions(value: string): { length: string; width: string; height: string } | null {
const parts = value
.replace(/[×x]/gi, '*')
.split('*')
.map((part) => part.trim());
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(Number(part)))) return null;
return { length: parts[0], width: parts[1], height: parts[2] };
}
export function parsePackageSpecs(raw: unknown): Prisma.InputJsonValue | null {
const table = parseTable(raw);
if (!table) return null;
const rows = table.slice(1)
.map((row, rowIndex) => {
const sizeName = cell(row, 0);
if (!sizeName) return null;
return {
sizeId: `size_${rowIndex}`,
sizeName,
dimensionsCm: dimensions(cell(row, 1)),
dimensionsIn: dimensions(cell(row, 2)),
volumeCm3: cell(row, 3) || null,
volumeIn3: cell(row, 4) || null,
grossWeightG: cell(row, 5) || null,
grossWeightLb: cell(row, 6) || null,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
return { rows } as Prisma.InputJsonValue;
}
function normalizeOptions(detail: SdsProductDetail): Prisma.InputJsonValue | null {
const attributers = detail.subproducts?.attributers;
if (!Array.isArray(attributers)) return null;
const sizeMap = new Map<string, { id: string; name: string; sortOrder: number; enabled: boolean }>();
const colorMap = new Map<string, { id: string; name: string; hex: string | null; sortOrder: number; enabled: boolean }>();
attributers.forEach((attribute, sizeIndex) => {
const sizeName = text(attribute.size);
const sizeId = text(attribute.sizeId) ?? `size_${sizeIndex}`;
if (sizeName) sizeMap.set(sizeId, { id: sizeId, name: sizeName, sortOrder: sizeIndex, enabled: true });
if (Array.isArray(attribute.colors)) {
attribute.colors.forEach((color, colorIndex) => {
const colorId = text(color.colorId) ?? `color_${colorIndex}`;
if (!colorMap.has(colorId)) {
colorMap.set(colorId, {
id: colorId,
name: text(color.chineseName) ?? text(color.color_name) ?? colorId,
hex: text(color.color),
sortOrder: integer(color.colorSort) ?? colorIndex,
enabled: true,
});
}
});
}
});
return { sizes: [...sizeMap.values()], colors: [...colorMap.values()] } as Prisma.InputJsonValue;
}
function normalizeMedia(detail: SdsProductDetail, variants: SdsProductVariant[]): Prisma.InputJsonValue | null {
const urls: string[] = [];
const add = (value: unknown) => {
const url = text(value);
if (url && !urls.includes(url)) urls.push(url);
};
add(detail.blankDesignUrl);
add(detail.psd_img_url);
add(detail.img_url);
for (const variant of variants) {
add(variant.psd_img_url);
add(variant.img_url);
add(variant.blankDesignUrl);
for (const image of variant.designPrototype?.detailImgUrls ?? []) add(image.imageUrl);
for (const image of variant.designPrototype?.prototypeResultGroups ?? []) add(image.resultImage);
}
if (urls.length === 0) return null;
return {
primaryImageUrl: urls[0],
images: urls.map((url, index) => ({ id: `image_${index}`, url, sortOrder: index })),
} as Prisma.InputJsonValue;
}
function normalizeVariant(variant: SdsProductVariant, index: number): NormalizedVariant | null {
const sdsVariantId = text(variant.id);
const sku = text(variant.sku);
if (!sdsVariantId || !sku) return null;
return {
sdsVariantId,
sku,
sizeId: text(variant.sizeId) ?? text(variant.sizeDto?.id),
sizeName: text(variant.size) ?? text(variant.sizeDto?.sizeName),
colorId: text(variant.colorId) ?? text(variant.color?.colorId),
colorName: text(variant.color?.chineseName) ?? text(variant.color_name) ?? text(variant.color?.color_name),
colorHex: text(variant.color?.color),
imageUrl: text(variant.psd_img_url) ?? text(variant.img_url) ?? text(variant.blankDesignUrl),
price: decimal(variant.currentPrice ?? variant.unit_price ?? variant.min_price),
originalPrice: decimal(variant.originalPrice),
weightG: decimal(variant.weight),
boxLengthCm: decimal(variant.box_length),
boxWidthCm: decimal(variant.box_width),
boxHeightCm: decimal(variant.box_height),
enabled: Number(variant.status ?? 1) === 1 && String(variant.delFlag ?? '0') === '0',
sortOrder: integer(variant.attribute_sort?.split('-')[0]) ?? integer(variant.size_sort) ?? index,
designData: variant.designPrototype ? (variant.designPrototype as Prisma.InputJsonValue) : null,
};
}
export function normalizeProductDetail(detail: SdsProductDetail): NormalizedProductDetail {
const productDetails = detail.product_details ?? {};
const sourceVariants = Array.isArray(detail.subproducts?.items) ? detail.subproducts!.items! : [];
const variants = sourceVariants
.map(normalizeVariant)
.filter((variant): variant is NormalizedVariant => variant !== null);
const updatedAt = Number(detail.updateTime);
return {
productCode: text(detail.sku),
englishName: text(detail.english_name),
blankDesignUrl: text(detail.blankDesignUrl),
detailsPageVideoUrl: text(detail.detailsPageVideoUrl),
textureName: text(detail.texture?.name),
productionCycleHours: integer(detail.productionCycle),
minWeightG: decimal(detail.minWeight),
reminder: text(productDetails.reminder),
productionProcess: text(productDetails.production_process),
materialDescription: text(productDetails.material_description),
productPerformance: text(productDetails.product_performance),
applicableScenarios: text(productDetails.applicable_scenarios),
washingInstructions: text(productDetails.washing_instructions),
specialDescription: text(productDetails.special_description),
designExplanation: text(productDetails.design_explanation),
designArea: text(productDetails.design_area),
pictureRequest: text(productDetails.picture_request),
sizeChart: parseSizeChart(productDetails.product_size),
packageSpecs: parsePackageSpecs(productDetails.packaging_specification),
options: normalizeOptions(detail),
media: normalizeMedia(detail, sourceVariants),
upstreamUpdatedAt: Number.isFinite(updatedAt) ? new Date(updatedAt) : null,
variants,
};
}
+13
View File
@@ -2,6 +2,7 @@ import {
Controller,
DefaultValuePipe,
Get,
Param,
ParseIntPipe,
Post,
Query,
@@ -36,6 +37,18 @@ export class SyncController {
return this.service.startProductSync();
}
@Post('product-details')
@ApiOperation({ summary: 'Manually sync details for all active origin products (async)' })
async syncProductDetails() {
return this.service.startProductDetailSync();
}
@Post('products/:goodId/detail')
@ApiOperation({ summary: 'Immediately sync one SDS product detail' })
async syncOneProductDetail(@Param('goodId') goodId: string) {
return this.service.syncOneProductDetail(goodId);
}
@Get('status')
@ApiOperation({ summary: 'Recent sync log entries' })
@ApiQuery({ name: 'limit', required: false, type: Number })
+68
View File
@@ -19,6 +19,7 @@ describe('SyncService', () => {
const sdsMock: Partial<SdsClientService> = {
fetchCategoryTree: jest.fn(),
fetchProductsPage: jest.fn(),
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
};
const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true })],
@@ -29,6 +30,9 @@ describe('SyncService', () => {
],
}).compile();
service = moduleRef.get(SyncService);
jest
.spyOn(service, 'syncConfiguredProductDetails')
.mockResolvedValue({ synced: 0, failed: 0 });
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
@@ -272,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);
});
});
+294 -7
View File
@@ -1,8 +1,19 @@
import { Injectable, Logger } 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';
import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service';
import {
SdsClientService,
SdsCategoryTreeNode,
SdsProduct,
SdsProductDetail,
} from './sds-client.service';
import { normalizeProductDetail } from './sds-product-detail.mapper';
export interface CategorySyncResult {
inserted: number;
@@ -59,7 +70,7 @@ export function shouldRunDelistDetection(leafCategories: number, seenGoods: numb
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
private running = { categories: false, products: false };
private running = { categories: false, products: false, details: false };
constructor(
private readonly prisma: PrismaService,
@@ -102,8 +113,28 @@ 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' };
}
void this.syncProductDetails().catch((err) =>
this.logger.error('Product detail sync failed', err as Error),
);
return { message: 'Product detail sync started' };
}
/** Check if a sync type is currently running. */
isRunning(type: 'categories' | 'products'): boolean {
isRunning(type: 'categories' | 'products' | 'details'): boolean {
return this.running[type];
}
@@ -323,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;
@@ -347,7 +386,13 @@ export class SyncService {
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
},
});
return { inserted, updated, total, leafCategories: leafRows.length, delisted: delistedCount };
return {
inserted,
updated,
total,
leafCategories: leafRows.length,
delisted: delistedCount,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.prisma.syncLog.update({
@@ -371,6 +416,248 @@ export class SyncService {
});
}
async syncProductDetails(): Promise<{
total: number;
synced: number;
failed: number;
}> {
if (this.running.details) {
throw new Error('Product detail sync already in progress');
}
this.running.details = true;
const log = await this.prisma.syncLog.create({
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
});
try {
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: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
},
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.prisma.syncLog.update({
where: { id: log.id },
data: { status: 'FAILED', finishedAt: new Date(), message },
});
throw error;
} finally {
this.running.details = false;
}
}
async syncOneProductDetail(goodId: string): Promise<{
goodId: string;
variants: number;
detailSyncedAt: string;
}> {
const originGood = await this.prisma.originGood.findUnique({
where: { sdsGoodId: goodId },
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);
return {
goodId,
variants: normalized.variants.length,
detailSyncedAt: new Date().toISOString(),
};
}
queueProductDetailSync(goodId: string): void {
void this.syncOneProductDetail(goodId).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Queued detail sync failed for ${goodId}: ${message}`);
});
}
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
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;
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 = 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 { total: originGoods.length, synced, failed };
}
async importProductDetail(upstream: SdsProductDetail): Promise<{
goodId: string;
variants: number;
sizeRows: number;
packageRows: number;
configuredGoods: number;
}> {
const goodId = String(upstream.id);
const normalized = normalizeProductDetail(upstream);
const originGood = await this.prisma.originGood.upsert({
where: { sdsGoodId: goodId },
create: {
sdsGoodId: goodId,
goodName: String(upstream.name ?? goodId),
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || null,
goodPrice:
upstream.min_price === undefined || upstream.min_price === null
? null
: new Prisma.Decimal(Number(upstream.min_price)),
},
update: {
goodName: upstream.name ? String(upstream.name) : undefined,
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || undefined,
goodPrice:
upstream.min_price === undefined || upstream.min_price === null
? undefined
: new Prisma.Decimal(Number(upstream.min_price)),
},
});
await this.persistProductDetail(originGood.id, upstream);
const configuredGoods = await this.prisma.good.count({
where: { originGoodId: originGood.id },
});
const sizeChart = normalized.sizeChart as { rows?: unknown[] } | null;
const packageSpecs = normalized.packageSpecs as { rows?: unknown[] } | null;
return {
goodId,
variants: normalized.variants.length,
sizeRows: sizeChart?.rows?.length ?? 0,
packageRows: packageSpecs?.rows?.length ?? 0,
configuredGoods,
};
}
private async persistProductDetail(originGoodId: bigint, upstream: SdsProductDetail): Promise<void> {
const normalized = normalizeProductDetail(upstream);
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: {
originGoodId,
...detail,
sizeChart: json(detail.sizeChart),
packageSpecs: json(detail.packageSpecs),
options: json(detail.options),
media: json(detail.media),
},
update: {
...detail,
sizeChart: json(detail.sizeChart),
packageSpecs: json(detail.packageSpecs),
options: json(detail.options),
media: json(detail.media),
syncedAt: new Date(),
},
});
const seenVariantIds: string[] = [];
for (const variant of variants) {
seenVariantIds.push(variant.sdsVariantId);
const { designData, ...data } = variant;
await tx.originGoodVariant.upsert({
where: {
originGoodId_sdsVariantId: {
originGoodId,
sdsVariantId: variant.sdsVariantId,
},
},
create: { originGoodId, ...data, designData: json(designData) },
update: { ...data, designData: json(designData) },
});
}
await tx.originGoodVariant.deleteMany({
where: {
originGoodId,
...(seenVariantIds.length ? { sdsVariantId: { notIn: seenVariantIds } } : {}),
},
});
});
}
/**
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
*/
+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
@@ -145,7 +145,7 @@ interface BackendTag {
}
interface BackendProduct {
id: string;
goodId: string;
goodName: string;
goodPriority: number;
country: { id: string; countryName: string; countryIcon: string | null };
@@ -243,7 +243,7 @@ export function resolveDefaultProductFilters(
function mapProduct(p: BackendProduct, backendUrl: string): Product {
return {
id: p.id,
id: p.goodId,
name: p.goodName,
priority: p.goodPriority,
image: resolveBackendAssetUrl(p.image, backendUrl),
@@ -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'],
+202
View File
@@ -0,0 +1,202 @@
# 商品数据清洗(Goods Data Cleaning
本目录存放商品数据清洗相关的全部脚本与规则文件。清洗目标:把右侧 SDS 原产品库
`origin_goods`)按规则批量配置为左侧官网商品(`goods`),替代后台手动逐个点击。
## 转换规则调查(2026-08-27,基于生产环境只读数据)
> 数据来源:`https://official.inkreach.cc/api` 生产接口(JWT 只读拉取):
> `/origin-goods?page=1..3&pageSize=200`(共 552 条)、`/goods?page=1..2&pageSize=200`(共 240 条)、
> `/tags`、`/categories`、`/countries`。未做任何写操作。
> 原始 JSON 快照存于 `runs/survey-2026-08-27/`(已加入 .gitignore,不入库)。
### 1. 右侧原产品(origin_goods)命名结构
右侧只有**一个字符串** `goodName`,整体模式:
```
国家(物流备注)品名-SKU-工艺位置[-仓库名]
↑ ↑ ↑ ↑ ↑
| | | | └─ 可选第4段:美西洛杉矶一仓 / 美中亚特兰大仓 等
| | | └─ 单面印花 / 双面印花 / 直喷双面 / 直喷单面 / 不打印 ...
| | └─ SKU 编码(字母+数字,如 DG001 / C1717 / KRHM003
| └─ 包邮 / 不包邮 / DHL包邮*运费结算订单时支付 / 不包邮光板 等变体
└─ 美国/德国/韩国...(全角括号为主,少量半角混用)
```
统计(552 条):
- **541 条**匹配 `国家(备注)其余` 模式;**11 条异常**
- 4 条自定义商品(`Goods Origin ...`source=CUSTOM
- 2 条缺国家前缀:`(不包邮)230g水洗炒雪花T恤-双面印花` / `(不包邮)180g纯棉T恤成人款-...`
- 1 条用空格代替括号:`法国 180g纯棉T恤-FRTM001-双面印花`
- 2 条半角左括号混用:`美国(不包邮)女士高弹中长款瑜伽运动裤-JSD004-...`
- 2 条同上变体
- 「备注)之后」按 `-` 分段:**387 条 = 3 段**(品名-SKU-工艺)、**154 条 = 4 段**(品名-SKU-工艺-仓库)
- 物流备注出现的变体形态(示例):`包邮``不包邮``DHL包邮``DHL包邮*运费结算订单时支付``包邮*运费订单结算时支付``不包邮光板`
- 国家前缀出现过的值:美国、德国、英国、意大利、西班牙、西班牙直发、韩国、日本、加拿大、墨西哥、巴西、波兰、澳大利亚
### 2. 左侧已配置商品(goods)现状 —— 人工配置的实际结果
240 条 goods 记录,覆盖 **209 个**不同 origin_good_id**343 个 origin 尚未配置**)。
**没有系统级拆分逻辑**:前端 [GoodsView.vue](../apps/admin/src/views/goods/GoodsView.vue) 提交的是完整原名,
后端 [goods.service.ts](../apps/api/src/goods/goods.service.ts) create/batchCreate 也原样入库。
因此左侧名称与右端的差异(113/240 条被改过)全部是**人工在编辑弹窗里改的**,且存在多种风格并存。
改名风格分布(113 条改名的归类):
| 风格 | 数量 | 示例 |
|------|------|------|
| A. 全拆:去前缀去括号,品名+空格+SKU,丢弃工艺段 | 27 | `韩国(包邮)200g纯棉T恤-KRTM001-单面印花``200g纯棉T恤 KRTM001` |
| B. 半改:仅把长备注缩成「X包邮」,其余原样保留 | 86 | `德国(DHL包邮*运费结算订单时支付)230g水洗T恤-DETM002-单面印花``德国(DHL包邮)230g水洗T恤-DETM002-单面印花` |
| R0. 保持原名不动 | 127 | 多为后期配置(2026-06 后期 ~ 07),未加工 |
标签体系(生产库现有 3 组 7 个标签)与左侧使用情况:
| 分组 | 标签(id) | 左侧使用次数 |
|------|----------|--------------|
| 物流渠道 | 包邮(30) / 不包邮(31) | 87 / 153 ← 基本每个商品都挂了物流标签 |
| 印刷位置 | 双面印(35) / 单面印(34) | 14 / 14 ← 只有少数挂了 |
| 印刷工艺 | 烫画(32) / 直喷(33) / 不打印(42) | 11 / 3 / 1 |
其他字段:goodPriority 几乎全部为 5positionId 全部为空;
国家字段与名称里的国家前缀一致率 238/240(仅 `西班牙直发(...)` 两条归入了「西班牙」)。
### 3. 人工操作流程(脚本要模拟的完整动作)
后台右侧树有「仅未配置」筛选按钮,人工实际是**两步操作**:
**第一步:配置(右→左创建)**
1. 右侧找到未配置的原产品,点击「配置」
2. 弹窗显示:原产品名(**纯文本,不可编辑**,[L1716](../apps/admin/src/views/goods/GoodsView.vue#L1716))、预览图片(可改但默认回填)
3. 人工选择三项:**国家、分类、标签**(名称在此步不能改)
4. 确认提交 → `POST /goods`,goodName 直接传原产品完整原名([L403](../apps/admin/src/views/goods/GoodsView.vue#L403)
**第二步:编辑改名(左侧已有记录上改)**
1. 左侧找到刚配置的商品,点击「编辑」
2. 编辑弹窗有 `el-input` 绑定 goodName[L1821](../apps/admin/src/views/goods/GoodsView.vue#L1821)),这里才能改名
3. 保存 → `PATCH /goods/:id`[L690](../apps/admin/src/views/goods/GoodsView.vue#L690)
因此脚本的**实际操作序列**是:先 `POST /goods`(用原名创建)→ 再 `PATCH /goods/:id`(改名)
弹窗字段与 API 参数对应:
| 步骤 | 弹窗字段 | API 参数 | 说明 |
|------|----------|----------|------|
| 配置 | 国家 | `countryId` | 必填 |
| 配置 | 分类 | `categoryId` | 必填 |
| 配置 | 标签 | `tagIds[]` | 写入 good_tags 中间表 |
| 配置 | 预览图片 | `goodImage` | 默认回填 origin_good.goodImage |
| 配置 | 名称 | `goodName` | **不可编辑**,自动传原名 |
| 编辑 | 名称 | `goodName` | **这一步才能改**,调 PATCH |
| 编辑 | 其余字段 | 同上 | 也可在编辑时调整 |
### 4. 定稿转换规则
> 以下规则已确认,脚本按此执行。
#### 4.1 整体策略
- **方案:API 驱动**(不导出/导入 DB,避免 BigInt 自增序列和外键约束问题)
- 脚本调用 `POST /goods` 逐条配置,走现有业务逻辑(校验、事务、标签关联全部由后端处理)
- 幂等:已存在的 (originGoodId, countryId) 组合跳过
- scope:仅处理**未配置**的原产品(configuredCount === 0),已配置的不动
#### 4.2 名称解析与改名规则
原产品名格式:`国家(物流备注)品名-SKU-工艺位置[-仓库名]`
**改名规则**:保留到 `-` 分隔的第 2 段,用空格连接,丢弃后续段(工艺、仓库)。
```
输入: 美国(包邮)180g纯棉T恤成人款-DG001-单面印花
解析: 品名="180g纯棉T恤成人款" SKU="DG001" 工艺="单面印花"(丢弃)
输出: goodName = "180g纯棉T恤成人款 DG001"
```
- 仓库名后缀(第 4 段,如「美西洛杉矶一仓」)直接丢弃
- 异常名称(缺国家前缀、半角括号等)单独输出到报告,不自动处理
#### 4.3 国家
- 从名称第一个 `` 之前提取国家文本
- 通过 `GET /countries` 拿到 countries 表,按 `countryName` 精确匹配 → `countryId`
- 特殊映射:「西班牙直发」→ 匹配「西班牙」
- 匹配不到的 → 输出到报告,不自动处理
#### 4.4 分类(品类)
- 右侧树已经按 categories 树分组(通过 `origin_goods.sds_category_id``categories.sds_category_id` 桥接)
- 同一原产品的分类就是它在右侧树中所挂的分类节点,直接取该节点的 `categoryId`
- 未分类的(sdsCategoryId 无匹配)→ 输出到报告
#### 4.5 标签(3 组,从名称解析)
标签体系(3 组 7 个):
| 分组 | 标签 | id | 解析规则 |
|------|------|----|----------|
| 物流渠道 | 包邮 / 不包邮 | 30 / 31 | 括号内含「包邮」→ 包邮(30),否则 → 不包邮(31) |
| 印刷位置 | 单面印 / 双面印 | 34 / 35 | 工艺段含「单面」→ 单面印(34),含「双面」→ 双面印(35) |
| 印刷工艺 | 烫画 / 直喷 / 不打印 | 32 / 33 / 42 | 工艺段含「直喷」→ 直喷(33);含「不打印」或「光板」→ 不打印(42);其余默认 → 烫画(32) |
工艺段 = `-` 分段的最后一段(去仓库段后),如 `单面印花``直喷双面``不打印``烫画`
解析示例:
```
美国(不包邮)180g纯棉T恤成人款-DG001-单面印花
→ 物流: 不包邮(31), 印刷位置: 单面印(34), 印刷工艺: 烫画(32)
美国(不包邮)207G重磅纯棉T恤-C1717-直喷双面
→ 物流: 不包邮(31), 印刷位置: 双面印(35), 印刷工艺: 直喷(33)
美国(不包邮光板)180g纯棉T恤成人款-DG001-不打印
→ 物流: 不包邮(31), 印刷位置: 跳过(工艺段=不打印无法判断单双面), 印刷工艺: 不打印(42)
```
#### 4.6 其他字段
- `goodImage`:直接用 origin_good.goodImage,不改
- `goodPriority`:默认 5(与现有 236/240 条一致)
- `positionId`:不填(与现有 240/240 条一致)
---
## 执行流水线(API 驱动方案)
```
① 拉取只读数据
调 GET /origin-goods/tree(含 configuredCount)、/tags、/categories、/countries
存到 runs/<date>/snapshot/
② 本地解析脚本 sync-plan.mjs
读取快照 → 筛选未配置 → 逐条解析名称 → 生成待创建指令列表
输出: runs/<date>/plan.json(每条包含 originGoodId/countryId/categoryId/goodName/tagIds/goodImage
+ runs/<date>/plan-report.md(人工检查点:共 N 条待创建、X 条异常需人工确认)
③ 人工审查 plan.json 和 plan-report.md
④ 执行脚本 sync-apply.mjs
读取 plan.json → 逐条 POST /goods(带 JWT,自动刷新 token
每条打印 +/skip/error;失败自动重试 3 次;生成 apply-report.md
⑤ 验证:调 /origin-goods/tree 确认未配置数归零,调 /goods 抽查新记录
```
原则:
- 不导出/导入数据库,全部通过 API 操作
- ② 和 ④ 分离:先生成计划供人工审查,确认后再执行
- 每步产物存 runs/(已 gitignore
- token 30 分钟过期,脚本内自动用 /auth/login 刷新
## 目录内容规划
| 文件 | 状态 | 说明 |
|------|------|------|
| `sync-rules.config.mjs` | 待创建 | 国家映射/异常处理等配置项(解析规则已定稿在上文) |
| `sync-plan.mjs` | 待创建 | 拉取 + 解析 + 生成 plan.json |
| `sync-apply.mjs` | 待创建 | 读取 plan.json + 逐条 POST /goods |
| `runs/` | 已创建 | 调查快照 survey-2026-08-27/ 已存在;后续每次运行产物按日期归档 |
详细实施计划见 [plans/feature/goods-data-cleaning-feature.md](../plans/feature/goods-data-cleaning-feature.md)。
+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');
+2
View File
@@ -3,6 +3,8 @@
"private": true,
"scripts": {
"dev": "turbo run dev",
"dev:admin": "turbo run dev --filter=@inkreach/api --filter=@inkreach/admin",
"dev:api": "pnpm --filter @inkreach/api dev",
"build": "turbo run build",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,js,json,md,vue}\"",
@@ -0,0 +1,280 @@
# Goods Data Cleaning Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 用「备份 → 导出 → 脚本转换 → 导入 → 验证」的流水线,把右侧 SDS 原产品库(`origin_goods`)按规则批量配置为左侧官网商品(`goods`),替代后台手动逐个点击。直接作用于生产库。
**Background:**
- 后台 GoodsView 已实现手动配置能力:右侧树选原产品 → 选国家/品类/标签 → 生成 `goods` 记录。
- 手动逐个点击效率低,需要脚本化批量处理。
- 同步规则(品类映射 / 国家分配 / 标签分配 / 优先级策略)**尚未确定**,将在后续数据清洗计划中明确后填入 `data-cleaning/sync-rules.config.mjs`
**Architecture:** 三步式流水线,复用现有 export/import 脚本,只新写转换脚本与独立规则文件。规则文件占位先行——改规则不改代码。转换脚本为纯函数式(JSON in → JSON out),不直接连接数据库;导入前输出变更摘要报告作为人工检查点;导入前必须完成全量备份保证可回滚。
**Tech Stack:** Node.js ESM 脚本、@prisma/client(仅 export/import 使用)、PostgreSQL。
**目录约定:**
- 清洗相关脚本/规则/产物统一放根目录 `data-cleaning/`(见 [data-cleaning/README.md](../../data-cleaning/README.md)
- 复用现有 [apps/api/scripts/export-data.mjs](../../apps/api/scripts/export-data.mjs) 与 [apps/api/scripts/import-data.mjs](../../apps/api/scripts/import-data.mjs)
---
## 流水线总览
```
[生产库]
│ ① pg_dump 全量备份
② node scripts/export-data.mjs data-cleaning/runs/<date>/export.json
③ node data-cleaning/sync-transform.mjs data-cleaning/runs/<date>/export.json
→ 输出 transformed.json + change-report.md(人工检查点)
④ node scripts/import-data.mjs data-cleaning/runs/<date>/transformed.json
⑤ 验证:行数对比 / 抽查商品配置 / 官网公开 API 抽查
```
### Task 1: 创建规则文件占位 `sync-rules.config.mjs`
**Files:**
- Create: `data-cleaning/sync-rules.config.mjs`
- Test: 无(纯数据文件,由 Task 2 的测试覆盖加载逻辑)
- [x] **Step 1: 创建规则文件骨架**
```js
/**
* 商品数据清洗 — 同步规则配置
*
* 规则尚未确定。确定后只修改本文件,不改 sync-transform.mjs。
* 字段语义在规则确定时补充说明。
*/
export const rules = {
/** 品类映射:SDS 品类 → 本地品类(待定) */
categoryMapping: {
// '<sds_category_id 或名称>': '<本地 category_id 或名称>',
},
/** 国家分配:每条原产品生成哪些国家的 good(待定) */
countryAssignment: {
mode: 'none', // none | all | fixed | perCategory
fixedCountryIds: [],
perCategory: {},
},
/** 标签分配:新 good 挂哪些 tag(待定) */
tagAssignment: {
mode: 'none', // none | fixed | perCategory
fixedTagIds: [],
perCategory: {},
},
/** 优先级策略(待定) */
priority: {
defaultPriority: 0,
},
};
```
- [x] **Step 2: Commit**
```bash
git add data-cleaning/sync-rules.config.mjs
git commit -m "feat(data-cleaning): add sync rules config placeholder"
```
### Task 2: 创建转换脚本 `sync-transform.mjs`TDD
**Files:**
- Create: `data-cleaning/sync-transform.mjs`
- Test: `data-cleaning/sync-transform.test.mjs`
- [x] **Step 1: 写失败测试**
```js
// data-cleaning/sync-transform.test.mjs
import { describe, it, expect } from 'vitest';
import { transform } from './sync-transform.mjs';
import { rules } from './sync-rules.config.mjs';
const baseDump = () => ({
exportedAt: new Date().toISOString(),
tables: {
users: [], countries: [], categories: [], tag_groups: [], tags: [],
positions: [],
origin_goods: [
{ id: '1', sds_good_id: 'SDS-A', good_name: 'A', delisted: 'false', is_custom: 'false' },
{ id: '2', sds_good_id: 'SDS-B', good_name: 'B', delisted: 'true', is_custom: 'false' },
],
origin_good_variants: [], origin_good_details: [],
goods: [], good_tags: [], sync_logs: [],
},
});
describe('transform', () => {
it('rules 为空时原样返回且报告零变更', () => {
const dump = baseDump();
const { result, report } = transform(dump, rules);
expect(result.tables.goods).toHaveLength(0);
expect(report.created).toBe(0);
expect(result.tables.origin_goods).toHaveLength(2);
});
it('跳过已下架原产品', () => {
const dump = baseDump();
const testRules = { ...rules, countryAssignment: { mode: 'all' } };
const { report } = transform(dump, testRules);
// 只有未下架的 id=1 会生成 good
expect(report.created).toBe(1);
expect(report.skippedDelisted).toBe(1);
});
it('幂等:已存在的 (originGoodId,countryId) 不重复创建', () => {
const dump = baseDump();
dump.tables.goods = [
{ id: '100', origin_good_id: '1', country_id: '9', good_name: 'A', good_priority: '0' },
];
dump.tables.countries = [{ id: '9', country_name: 'US' }];
const testRules = {
...rules,
countryAssignment: { mode: 'fixed', fixedCountryIds: ['9'] },
};
const { report } = transform(dump, testRules);
expect(report.created).toBe(0);
expect(report.duplicates).toBe(1);
});
});
```
> 注:若仓库未配置 vitest 运行 `.mjs`,可用 `node --test`node:test)替代,断言等价改写。
- [x] **Step 2: 运行测试确认 RED**
Run: `cd apps/api && npx vitest run ../../data-cleaning/sync-transform.test.mjs`(或 `node --test data-cleaning/`
Expected: FAIL(模块不存在)
- [x] **Step 3: 最小实现**
```js
// data-cleaning/sync-transform.mjs
/**
* 数据转换脚本(纯函数式):读取导出 JSON + 规则文件,
* 输出待导入 JSONtransformed.json)与变更摘要(change-report.md)。
*
* Usage:
* node data-cleaning/sync-transform.mjs <export.json>
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { rules } from './sync-rules.config.mjs';
/** 纯函数:dump + rules → { result, report } */
export function transform(dump, rulesConfig) {
const tables = JSON.parse(JSON.stringify(dump.tables)); // deep clone
const countries = tables.countries;
const existingKeys = new Set(
tables.goods.map((g) => `${g.origin_good_id}:${g.country_id}`),
);
const report = { created: 0, duplicates: 0, skippedDelisted: 0, details: [] };
const activeCountries = countries.filter((c) => c.id);
for (const og of tables.origin_goods) {
if (og.delisted === 'true' || og.delisted === true) {
if (og.is_custom !== 'true' && og.is_custom !== true) report.skippedDelisted++;
continue;
}
let targets = [];
if (rulesConfig.countryAssignment.mode === 'all') {
targets = activeCountries.map((c) => c.id);
} else if (rulesConfig.countryAssignment.mode === 'fixed') {
targets = rulesConfig.countryAssignment.fixedCountryIds;
}
for (const countryId of targets) {
const key = `${og.id}:${countryId}`;
if (existingKeys.has(key)) { report.duplicates++; continue; }
const newId = String(
tables.goods.reduce((m, g) => Math.max(m, Number(g.id || 0)), 0) + 1,
);
tables.goods.push({
id: newId,
origin_good_id: og.id,
country_id: countryId,
category_id: rulesConfig.categoryMapping.default ?? '',
good_name: og.good_name,
good_priority: String(rulesConfig.priority.defaultPriority ?? 0),
});
existingKeys.add(key);
report.created++;
report.details.push(`+ good[${newId}] origin=${og.sds_good_id} country=${countryId}`);
}
}
return { result: { exportedAt: dump.exportedAt, tables }, report };
}
function main() {
const input = process.argv[2];
if (!input) {
console.error('Usage: node data-cleaning/sync-transform.mjs <export.json>');
process.exit(1);
}
const dump = JSON.parse(readFileSync(input, 'utf8'));
const { result, report } = transform(dump, rules);
mkdirSync(`${dirname(input)}/out`, { recursive: true });
writeFileSync(`${dirname(input)}/out/transformed.json`, JSON.stringify(result));
writeFileSync(
`${dirname(input)}/out/change-report.md`,
['# 变更摘要', `- 新增 goods: ${report.created}`, `- 重复跳过: ${report.duplicates}`,
`- 下架跳过: ${report.skippedDelisted}`, '', ...report.details.map((d) => `- ${d}`)].join('\n'),
);
console.log(JSON.stringify(report, null, 2));
}
// 测试环境下不自动执行 main
if (process.env.NODE_ENV !== 'test' && import.meta.url === `file://${process.argv[1]}`) {
main();
}
```
- [x] **Step 4: 运行测试确认 GREEN**
Run: `cd apps/api && npx vitest run ../../data-cleaning/sync-transform.test.mjs`
Expected: PASS3 个用例全过)
- [x] **Step 5: Commit**
```bash
git add data-cleaning/sync-transform.mjs data-cleaning/sync-transform.test.mjs
git commit -m "feat(data-cleaning): add pure transform script with tests"
```
### Task 3: 生产执行手册写入 README 并联调演练
**Files:**
- Modify: `data-cleaning/README.md`
- [x] **Step 1: 补充执行命令段**(备份 / 导出 / 转换 / 人工检查 / 导入 / 验证 六个步骤的确切命令与预期输出)
- [x] **Step 2: 本地或预发演练一次全流程**(规则为空应零变更),确认 change-report 为空、行数一致
- [x] **Step 3: Commit**
```bash
git add data-cleaning/README.md
git commit -m "docs(data-cleaning): add production runbook"
```
---
## 待定事项(规则确定后回填)
| 项 | 状态 | 回填位置 |
|----|------|----------|
| 品类映射规则 | 未定 | `data-cleaning/sync-rules.config.mjs#categoryMapping` |
| 国家分配策略 | 未定 | `...#countryAssignment` |
| 标签分配策略 | 未定 | `...#tagAssignment` |
| 优先级策略 | 未定 | `...#priority` |
| 规则细节文档 | 未定 | 后续数据清洗计划文件夹(本目录)内新建规则说明 md |
+54
View File
@@ -99,6 +99,9 @@ importers:
'@nestjs/swagger':
specifier: ^7.1.17
version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)
'@prisma/client':
specifier: ^5.8.0
version: 5.22.0(prisma@5.22.0)
@@ -117,9 +120,15 @@ importers:
class-validator:
specifier: ^0.14.0
version: 0.14.4
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
express:
specifier: ^4.21.0
version: 4.22.1
helmet:
specifier: ^8.3.0
version: 8.3.0
multer:
specifier: ^2.2.0
version: 2.2.0
@@ -148,6 +157,9 @@ importers:
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.25)
'@types/express':
specifier: ^4.17.21
version: 4.17.25
@@ -1401,6 +1413,13 @@ packages:
'@nestjs/platform-express':
optional: true
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
@@ -3372,6 +3391,11 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie-parser@1.4.10':
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
peerDependencies:
'@types/express': '*'
'@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
@@ -4542,6 +4566,13 @@ packages:
cookie-es@3.1.1:
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
@@ -5488,6 +5519,10 @@ packages:
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
helmet@8.3.0:
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
engines: {node: '>=18.0.0'}
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
@@ -9907,6 +9942,12 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@nestjs/throttler@6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.2.0': {}
@@ -11774,6 +11815,10 @@ snapshots:
dependencies:
'@types/node': 20.19.43
'@types/cookie-parser@1.4.10(@types/express@4.17.25)':
dependencies:
'@types/express': 4.17.25
'@types/cookiejar@2.1.5': {}
'@types/deep-eql@4.0.2': {}
@@ -13136,6 +13181,13 @@ snapshots:
cookie-es@3.1.1: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {}
cookie-signature@1.0.7: {}
cookie@0.7.2: {}
@@ -14247,6 +14299,8 @@ snapshots:
dependencies:
'@types/hast': 3.0.5
helmet@8.3.0: {}
hey-listen@1.0.8: {}
hookable@5.5.3: {}