Author SHA1 Message Date
yeuimu bbd925070a 部署h5 2026-08-28 15:23:54 +08:00
yeuimu 270e516f94 fix(public): media images are objects ({id,url,sortOrder}) - handle both shapes in mergeMedia 2026-08-28 11:22:53 +08:00
yeuimu d826c5bdd4 feat(public): merge secondary media gallery images with URL dedup 2026-08-28 11:19:27 +08:00
yeuimu 457792b729 feat(public): dedupe merged variants and merge specs/options from secondary origins 2026-08-28 11:05:34 +08:00
yeuimu 8233dda07a fix(admin): initial goods load also uses pageSize 1000 2026-08-28 09:49:40 +08:00
yeuimu cbdb507be9 fix(api): allow pageSize up to 1000 so admin goods tree can load full list 2026-08-28 09:36:15 +08:00
yeuimu b3bd10de06 fix(admin): load all goods in left tree (pageSize 1000) so low-priority new goods are searchable 2026-08-28 09:31:02 +08:00
yeuimu e7582bbd6e fix(deploy): add .dockerignore so host node_modules does not break image build 2026-08-27 19:13:57 +08:00
yeuimu 4c50189538 merge: multi-origin-good-merge-zll into develop 2026-08-27 18:51:28 +08:00
yeuimu 4bebb9e4b2 docs: document merged origin goods feature 2026-08-27 18:49:17 +08:00
yeuimu ac29a0c7bf feat(admin): show merged count badge on good nodes 2026-08-27 18:45:51 +08:00
yeuimu ac3a39f40d feat(admin): manage merged origin goods in good edit modal 2026-08-27 18:43:30 +08:00
yeuimu 491e74b905 feat(admin): add origin name grouping utils with vitest coverage 2026-08-27 18:40:22 +08:00
yeuimu 0aa6cbf1d9 feat(admin): suggest and merge sibling origin goods in config modal 2026-08-27 18:40:13 +08:00
yeuimu 697b82a64b fix(admin): resolve pre-existing template type errors blocking build 2026-08-27 18:40:08 +08:00
yeuimu f06dfffbda feat(public): resolve goods by secondary sds id and merge variants 2026-08-27 18:20:25 +08:00
yeuimu d9ecd04747 feat(origin-goods): include merged secondary references in tree stats 2026-08-27 18:18:29 +08:00
yeuimu 9c279ae393 feat(goods): support merged secondary origin goods in create/update/detail 2026-08-27 18:15:56 +08:00
yeuimu 848eed0b6f feat(db): add good_origin_goods junction table for merged origin goods 2026-08-27 18:09:00 +08:00
yeuimu 043d2463a6 docs(plan): add multi origin good merge design and implementation plan 2026-08-27 17:08:06 +08:00
yeuimu 005ab5b585 chore: restore files unintentionally deleted in 6c61a4e 2026-08-26 17:54:35 +08:00
yeuimu 6c61a4e871 feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
2026-08-26 14:23:09 +08:00
yeuimu be0b90e68f feat(security): HttpOnly cookie sessions, token revocation, and RBAC
- Add User.role (enum Role/ADMIN) and User.tokenVersion with migration
- Login now issues short-lived access token (30m default) + 7d refresh
  token, both embedding tokenVersion and a typ discriminator
- Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped
  to /auth); refresh token never leaves the cookie
- New endpoints: POST /auth/refresh (rotation), GET /auth/me,
  POST /auth/logout (bumps tokenVersion, revoking all tokens)
- JWT strategy accepts bearer or cookie, rejects refresh tokens, and
  verifies tokenVersion + user existence on every request
- Global RolesGuard: authenticated routes require ADMIN unless widened
  via @Roles(...)
- Admin SPA: session fully cookie-based, no token in localStorage;
  router guard restores session via /auth/me; axios auto-refreshes once
  on 401; stale localStorage keys cleaned up
2026-08-22 12:04:56 +08:00
yeuimu 755b40aded merge: security hardening fixes 2026-08-22 11:55:14 +08:00
yeuimu 9c1106586a fix(security): harden auth, upload, and API configuration
- Lock public registration to first-user bootstrap (403 afterwards)
- Require JwtAuthGuard on upload + whitelist png/jpg/webp/gif (SVG/XSS blocked)
- Add global throttling (login/register 5/min, upload 10/min)
- Add helmet security headers; serve uploads with nosniff
- Replace permissive CORS (origin:true+credentials) with CORS_ORIGINS whitelist
- Disable Swagger outside development; sanitize 500 error responses
- Enforce 32+ char JWT_SECRET; make token expiry configurable (TOKEN_EXPIRES_IN)
- Re-check user in DB on every JWT validation (revocation on user delete)
- Dummy bcrypt compare to prevent login user-enumeration via timing
- Map malformed BigInt inputs to 400 instead of 500
- Widen .gitignore to .env* and add apps/api/.env.example
- Disable Nuxt devtools and sourcemaps
2026-08-22 11:55:13 +08:00
yeuimu 9ed569f5bc fix(admin): simplify product forms 2026-08-21 14:54:08 +08:00
yeuimu b04623ebdd feat(goods): add editable custom products 2026-08-21 14:45:42 +08:00
yeuimu a4151607c5 fix(sync): hydrate all origin product details 2026-08-21 14:05:15 +08:00
yeuimu 4cc99f3f23 fix(docs): describe tags as grouped array 2026-08-21 11:44:25 +08:00
yeuimu 8b38d6fef7 refactor(api): group public tag filters 2026-08-21 11:34:57 +08:00
560 changed files with 79125 additions and 66038 deletions
+26
View File
@@ -0,0 +1,26 @@
# 依赖与包管理缓存(容器内会重新安装,禁止从宿主机带入)
node_modules
**/node_modules
.pnpm-store
.pnpm-debug.log
# 构建产物
dist
**/dist
.turbo
# Git 与本地文件
.git
.gitignore
.dockerignore
# 测试与文档
coverage
**/*.spec.ts
**/*.test.ts
# 运行数据与本地产物
data-cleaning
deploy/backups
apps/api/uploads
*.log
+8 -44
View File
@@ -1,46 +1,10 @@
# Dependencies
node_modules
# Build outputs
dist
.output
.nuxt
.nitro
.data
.cache
# TypeScript
*.tsbuildinfo
# Logs
logs
node_modules/
dist/
.turbo/
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Environment
.env
.env.local
.env.development
.env.*.local
# OS
.DS_Store
# IDE
.idea
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
# Coverage
coverage
# Nuxt
.output
.nuxt
.nitro
.cache
.env.*
# Deployment runtime data (certs, ACME state, data dumps)
deploy/certbot/
deploy/data-dump.json
uploads/
+3 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
@@ -27,6 +28,7 @@
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.12",
"vitest": "^4.1.10",
"vue-tsc": "^3.2.8"
}
}
+10
View File
@@ -8,6 +8,8 @@ import type {
BatchPriorityRequest,
GoodsFilter,
PaginatedResult,
CreateCustomGoodRequest,
UpdateCustomGoodContentRequest,
} from '@/types'
export const goodsApi = {
@@ -26,6 +28,14 @@ export const goodsApi = {
return request.post<any, Good>('/goods', data)
},
createCustomGood: (data: CreateCustomGoodRequest) => {
return request.post<any, GoodDetail>('/goods/custom', data)
},
updateCustomGoodContent: (id: string, data: UpdateCustomGoodContentRequest) => {
return request.patch<any, GoodDetail>(`/goods/${id}/custom-content`, data)
},
// Update good
updateGood: (id: string, data: UpdateGoodRequest) => {
return request.patch<any, Good>(`/goods/${id}`, data)
+27 -18
View File
@@ -1,5 +1,5 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
@@ -9,22 +9,10 @@ const request: AxiosInstance = axios.create({
headers: {
'Content-Type': 'application/json',
},
// Session tokens live in HttpOnly cookies — send them along.
withCredentials: true,
})
// Request interceptor
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// Response interceptor
request.interceptors.response.use(
(response: AxiosResponse) => {
@@ -35,15 +23,36 @@ request.interceptors.response.use(
}
return body
},
(error: AxiosError) => {
async (error: AxiosError) => {
// Access token expired: try the refresh cookie once, then retry the
// original request. A second 401 (refresh failed) logs the user out.
const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined
if (
error.response?.status === 401 &&
config &&
!config._retried &&
!config.url?.includes('/auth/login') &&
!config.url?.includes('/auth/refresh')
) {
config._retried = true
try {
await axios.post(
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
{},
{ withCredentials: true },
)
return request.request(config)
} catch {
// fall through to the 401 handling below
}
}
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
ElMessage.error('Unauthorized, please login')
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
break
case 403:
+5
View File
@@ -11,12 +11,14 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
@@ -33,15 +35,18 @@ declare module 'vue' {
ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
+1 -1
View File
@@ -28,7 +28,7 @@ const routes: RouteRecordRaw[] = [
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory('/admin/'),
routes,
})
+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,
+79
View File
@@ -42,6 +42,17 @@ export interface Good {
position?: Position
createdAt: string
updatedAt: string
mergedOriginGoods?: MergedOriginGoodSummary[]
}
export interface MergedOriginGoodSummary {
id: string
sdsGoodId: string
goodName: string | null
goodImage: string | null
goodPrice: string | null
hasDetail: boolean
variantCount: number
}
export interface OriginGoodDetail {
@@ -68,6 +79,49 @@ export interface OriginGoodVariant {
[key: string]: unknown
}
export interface CustomGoodVariantRequest {
sku: string
sizeId?: string | null
sizeName?: string | null
colorId?: string | null
colorName?: string | null
colorHex?: string | null
imageUrl?: string | null
price?: string | null
originalPrice?: string | null
weightG?: string | null
boxLengthCm?: string | null
boxWidthCm?: string | null
boxHeightCm?: string | null
designData?: Record<string, unknown> | null
enabled?: boolean
sortOrder?: number
}
export interface CustomGoodDetailRequest {
productCode?: string | null
englishName?: string | null
productionCycleHours?: number | null
minWeightG?: string | null
productionProcess?: string | null
materialDescription?: string | null
blankDesignUrl?: string | null
detailsPageVideoUrl?: string | null
textureName?: string | null
reminder?: string | null
productPerformance?: string | null
applicableScenarios?: string | null
washingInstructions?: string | null
specialDescription?: string | null
designExplanation?: string | null
designArea?: string | null
pictureRequest?: string | null
sizeChart?: Record<string, unknown> | null
packageSpecs?: Record<string, unknown> | null
options?: Record<string, unknown> | null
media?: Record<string, unknown> | null
}
export interface GoodDetail extends Good {
originDetail: OriginGoodDetail | null
variants: OriginGoodVariant[]
@@ -82,6 +136,28 @@ export interface CreateGoodRequest {
tagIds?: number[]
positionId?: number
goodPriority?: number
mergedOriginGoodIds?: number[]
}
export interface CreateCustomGoodRequest {
goodName: string
goodImage?: string
goodPrice?: string | null
countryId: number
categoryId: number
tagIds?: number[]
positionId?: number
goodPriority?: number
detail?: CustomGoodDetailRequest
variants?: CustomGoodVariantRequest[]
}
export interface UpdateCustomGoodContentRequest {
goodName?: string
goodImage?: string | null
goodPrice?: string | null
detail?: CustomGoodDetailRequest
variants?: CustomGoodVariantRequest[]
}
export interface UpdateGoodRequest {
@@ -93,6 +169,7 @@ export interface UpdateGoodRequest {
tagIds?: number[]
positionId?: number | null
goodPriority?: number
mergedOriginGoodIds?: number[]
}
export interface BatchCreateItem {
@@ -271,6 +348,8 @@ export interface OriginGood {
goodImage: string | null
goodPrice: string | null
sdsGoodId: string
source: 'SDS' | 'CUSTOM'
isCustom: boolean
sdsCategoryId: string | null
delisted?: boolean
createdAt: string
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { sameOriginGroup, truncateToProcess } from './origin-name';
describe('truncateToProcess', () => {
it('keeps first 3 dash segments (drops warehouse)', () => {
expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓'))
.toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花');
});
it('is identity for 3-segment names', () => {
expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'))
.toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面');
});
it('handles malformed names without country prefix', () => {
expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'))
.toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花');
});
it('returns empty for null/empty', () => {
expect(truncateToProcess(null)).toBe('');
expect(truncateToProcess('')).toBe('');
});
});
describe('sameOriginGroup', () => {
it('matches same product across warehouses', () => {
expect(sameOriginGroup(
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓',
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓',
)).toBe(true);
});
it('does not match different SKU', () => {
expect(sameOriginGroup(
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花',
'美国(包邮)180g纯棉T恤成人款-DG002-单面印花',
)).toBe(false);
});
it('does not match different country', () => {
expect(sameOriginGroup(
'美国(包邮)T恤-DG001-单面印花',
'德国(不包邮)T恤-DG001-单面印花',
)).toBe(false);
});
it('does not match empty names', () => {
expect(sameOriginGroup(null, 'x-y-z')).toBe(false);
expect(sameOriginGroup('', '')).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* 原产品名匹配规则(见 data-cleaning/README.md 命名调查):
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
* 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
* 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。
*/
const KEEP_SEGMENTS = 3;
export function truncateToProcess(name: string | null | undefined): string {
if (!name) return '';
return name.split('-').slice(0, KEEP_SEGMENTS).join('-');
}
export function sameOriginGroup(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const ka = truncateToProcess(a);
return ka !== '' && ka === truncateToProcess(b);
}
@@ -52,7 +52,6 @@ const dialogLoading = ref(false)
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
categoryName: '',
categoryIcon: '',
parentCategoryId: '',
})
const dialogRules = {
@@ -146,7 +145,7 @@ onMounted(fetchTree)
>
<el-table-column prop="categoryName" label="名称" min-width="220" />
<el-table-column label="图标" width="100">
<template #default="{ row }: { row: CategoryTree }">
<template #default="{ row }: any">
<el-image
v-if="row.categoryIcon"
:src="row.categoryIcon"
@@ -158,17 +157,17 @@ onMounted(fetchTree)
</template>
</el-table-column>
<el-table-column label="父级">
<template #default="{ row }: { row: CategoryTree }">
<template #default="{ row }: any">
{{ row.parent?.categoryName || '-' }}
</template>
</el-table-column>
<el-table-column label="子级数" width="100">
<template #default="{ row }: { row: CategoryTree }">
<template #default="{ row }: any">
{{ row._count?.children ?? row.children?.length ?? 0 }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: CategoryTree }">
<template #default="{ row }: any">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
@@ -23,7 +23,7 @@ const filter = reactive<Required<CountryFilter>>({
async function fetchList() {
loading.value = true
try {
const res = await countriesApi.getCountriesList(filter)
await countriesApi.getCountriesList(filter)
const data = await countriesApi.getCountriesList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
@@ -147,7 +147,7 @@ onMounted(fetchList)
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="图标" width="80">
<template #default="{ row }: { row: Country }">
<template #default="{ row }: any">
<el-image
v-if="row.countryIcon"
:src="row.countryIcon"
@@ -160,12 +160,12 @@ onMounted(fetchList)
</el-table-column>
<el-table-column prop="countryName" label="名称" min-width="200" />
<el-table-column label="创建时间" width="180">
<template #default="{ row }: { row: Country }">
<template #default="{ row }: any">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Country }">
<template #default="{ row }: any">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
+487 -52
View File
@@ -4,10 +4,11 @@ import { useVirtualList } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Edit, Delete, Search, Top, Refresh,
FolderAdd, Aim, ArrowDown,
FolderAdd, Aim, ArrowDown, QuestionFilled,
} from '@element-plus/icons-vue'
import type {
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, Position,
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
MergedOriginGoodSummary,
OriginGoodsTreeResponse,
} from '@/types'
import { goodsApi } from '@/api/goods'
@@ -15,9 +16,9 @@ import { countriesApi } from '@/api/countries'
import { categoriesApi } from '@/api/categories'
import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups'
import { positionsApi } from '@/api/positions'
import { originGoodsApi } from '@/api/origin-goods'
import { syncApi } from '@/api/sync'
import { sameOriginGroup } from '@/utils/origin-name'
const mode = ref<'category' | 'country' | 'global'>('category')
const loading = ref(false)
@@ -114,7 +115,7 @@ async function loadAll() {
categoriesApi.getCategoryTree(),
countriesApi.getCountriesList({ page: 1, pageSize: 200 } as any),
tagsApi.getTagsList({ page: 1, pageSize: 200 } as any),
goodsApi.getGoodsList({ page: 1, pageSize: 200 } as any),
goodsApi.getGoodsList({ page: 1, pageSize: 1000 } as any),
originGoodsApi.getTree(),
]) as any[]
allCategories.value = (Array.isArray(cats) ? cats : (cats.items ?? []))
@@ -170,7 +171,9 @@ function goodToNode(g: Good): any {
originGoodImage: g.originGood?.goodImage || null,
originGoodPrice: g.originGood?.goodPrice || null,
sdsGoodId: g.originGood?.sdsGoodId || null,
originDelisted: !!g.originGoodId && !activeOriginGoodIds.value.has(String(g.originGoodId)),
isCustom: g.originGood?.isCustom === true,
mergedCount: 1 + (g.mergedOriginGoods?.length ?? 0),
originDelisted: g.originGood?.source === 'SDS' && !activeOriginGoodIds.value.has(String(g.originGoodId)),
}
}
@@ -260,6 +263,7 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
label: og.goodName,
isOG: true,
rawId: og.id,
parentId: 'rc-' + node.categoryId,
goodName: og.goodName,
goodImage: og.goodImage,
goodPrice: og.goodPrice,
@@ -306,7 +310,7 @@ watch(searchKeyword, () => {
})
function refreshLeftTree() {
goodsApi.getGoodsList({ page: 1, pageSize: 200 } as any).then((res: any) => {
goodsApi.getGoodsList({ page: 1, pageSize: 1000 } as any).then((res: any) => {
allGoods.value = res?.items ?? []
})
}
@@ -364,11 +368,48 @@ const configForm = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '',
tagIds: [] as string[], positionId: '', goodImage: '',
})
const configPositions = ref<Position[]>([])
const configSiblings = ref<any[]>([])
const configChecked = ref<string[]>([])
const configPrimaryId = ref<string>('')
const checkedSiblingNodes = computed(() =>
configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))),
)
function findOgNodeById(rawId: string | number): any {
let found: any = null
function traverse(nodes: any[]) {
for (const n of nodes) {
if (n.isOG && String(n.rawId) === String(rawId)) { found = n; return }
if (n.children?.length) traverse(n.children)
}
}
traverse(rightTreeData.value)
return found
}
function collectSiblings(ogNode: any): any[] {
const result: any[] = []
function traverse(nodes: any[]) {
for (const n of nodes) {
if (n.isOG && n.parentId === ogNode.parentId && String(n.rawId) !== String(ogNode.rawId) && !n.configuredCount) result.push(n)
if (n.children?.length) traverse(n.children)
}
}
traverse(rightTreeData.value)
return result
}
function openConfigModal(og: any, dropTarget: any) {
configOG.value = og
configDropTarget.value = dropTarget
const ogNode = findOgNodeById(og.rawId)
const siblings = ogNode ? collectSiblings(ogNode) : []
configSiblings.value = siblings
configPrimaryId.value = String(og.rawId)
configChecked.value = siblings
.filter((s) => sameOriginGroup(ogNode?.goodName ?? og.goodName, s.goodName))
.map((s) => String(s.rawId))
configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', goodImage: og.goodImage || '' }
if (dropTarget) {
if (mode.value === 'category') {
@@ -378,7 +419,6 @@ function openConfigModal(og: any, dropTarget: any) {
configForm.value.countryId = dropTarget.id
}
}
loadConfigPositions()
configVisible.value = true
}
@@ -392,19 +432,8 @@ function openConfigFromRightTree(data: any) {
}, null)
}
async function loadConfigPositions() {
const params: any = { page: 1, pageSize: 200 }
if (configForm.value.countryId) params.countryId = configForm.value.countryId
if (configForm.value.categoryId) params.categoryId = configForm.value.categoryId
try {
const res = await positionsApi.getPositionsList(params) as any
configPositions.value = Array.isArray(res) ? res : (res.items ?? [])
} catch { configPositions.value = [] }
}
function onConfigCascaderChange(val: string[]) {
function onConfigCascaderChange(val: any) {
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
loadConfigPositions()
}
async function handleConfigSubmit() {
@@ -415,7 +444,10 @@ async function handleConfigSubmit() {
await goodsApi.createGood({
goodName: configOG.value.goodName,
goodImage: configForm.value.goodImage || undefined,
originGoodId: Number(configOG.value.rawId),
originGoodId: Number(configPrimaryId.value || configOG.value.rawId),
mergedOriginGoodIds: configChecked.value
.filter((id) => id !== configPrimaryId.value)
.map(Number),
countryId: Number(configForm.value.countryId),
categoryId: Number(configForm.value.categoryId),
tagIds: configForm.value.tagIds.map(Number),
@@ -430,12 +462,62 @@ async function handleConfigSubmit() {
} finally { configLoading.value = false }
}
// ─── Custom Good ───
const customVisible = ref(false)
const customLoading = ref(false)
const customForm = ref({
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[],
goodPriority: 0,
})
function openCustomCreate() {
customForm.value = {
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0,
}
customVisible.value = true
}
function onCustomCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
customForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function handleCustomCreate() {
if (!customForm.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return }
if (!customForm.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!customForm.value.categoryId) { ElMessage.warning('请选择分类'); return }
customLoading.value = true
try {
const created = await goodsApi.createCustomGood({
goodName: customForm.value.goodName.trim(),
goodImage: customForm.value.goodImage || undefined,
goodPrice: customForm.value.goodPrice || null,
countryId: Number(customForm.value.countryId),
categoryId: Number(customForm.value.categoryId),
tagIds: customForm.value.tagIds.map(Number),
goodPriority: customForm.value.goodPriority,
detail: {},
})
ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU')
customVisible.value = false
await refreshLeftTree()
await openEdit(created)
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '自定义商品创建失败')
} finally { customLoading.value = false }
}
// ─── Edit Good (replaces detail — click opens edit directly) ───
const editVisible = ref(false)
const editLoading = ref(false)
const editDetailLoading = ref(false)
const detailSyncing = ref(false)
const editGood = ref<Good | GoodDetail | null>(null)
const editMerged = ref<MergedOriginGoodSummary[]>([])
const editMergeSearch = ref('')
const originalOriginGoodId = ref<string>('')
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', tagIds: [] as string[], positionId: '',
@@ -455,9 +537,127 @@ const editSizeRows = computed(() => {
}))
})
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
// 候选 = 右栏树全部 og,按搜索词过滤
const editMergeCandidates = computed(() => {
if (!editMergeSearch.value) return []
const kw = editMergeSearch.value
const result: any[] = []
function traverse(nodes: any[]) {
for (const n of nodes) {
if (n.isOG && (n.goodName ?? '').includes(kw)) result.push(n)
if (n.children?.length) traverse(n.children)
}
}
traverse(rightTreeData.value)
return result.filter(
(n) => String(n.rawId) !== String(editGood.value?.originGoodId)
&& !editMerged.value.some((m) => m.id === String(n.rawId)),
)
})
function addEditMerge(node: any) {
editMerged.value.push({
id: String(node.rawId), sdsGoodId: node.sdsGoodId,
goodName: node.goodName, goodImage: node.goodImage,
goodPrice: node.goodPrice, hasDetail: Boolean(node.hasDetail),
variantCount: node.variantCount ?? 0,
})
editMergeSearch.value = ''
}
function promoteMerged(m: MergedOriginGoodSummary) {
if (!editGood.value) return
const oldPrimaryId = editGood.value.originGoodId
const oldPrimary = editGood.value.originGood
editGood.value = {
...editGood.value,
originGoodId: m.id,
originGood: oldPrimary
? { ...oldPrimary, id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName }
: { id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName } as any,
}
editMerged.value = [
{ id: oldPrimaryId, sdsGoodId: oldPrimary?.sdsGoodId ?? '', goodName: oldPrimary?.goodName ?? null,
goodImage: oldPrimary?.goodImage ?? null, goodPrice: oldPrimary?.goodPrice ?? null,
hasDetail: oldPrimary?.hasDetail ?? false, variantCount: (oldPrimary as any)?.variantCount ?? 0 },
...editMerged.value.filter((x) => x.id !== m.id),
]
}
const customContentForm = ref({
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
minWeightG: '', productionProcess: '', materialDescription: '',
blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '',
productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '',
designExplanation: '', designArea: '', pictureRequest: '',
sizeChartJson: '{\n "columns": [],\n "rows": []\n}',
packageSpecsJson: '{\n "rows": []\n}',
optionsJson: '{}',
mediaJson: '{}',
variants: [] as Array<{
sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string;
price: string; originalPrice: string; weightG: string; boxLengthCm: string;
boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean
}>,
})
function fillCustomContent(g: GoodDetail) {
const detail = g.originDetail ?? {}
customContentForm.value = {
goodPrice: g.originGood?.goodPrice ?? '',
productCode: String(detail.productCode ?? ''),
englishName: String(detail.englishName ?? ''),
productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours),
minWeightG: String(detail.minWeightG ?? ''),
productionProcess: String(detail.productionProcess ?? ''),
materialDescription: String(detail.materialDescription ?? ''),
blankDesignUrl: String(detail.blankDesignUrl ?? ''),
detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''),
textureName: String(detail.textureName ?? ''),
reminder: String(detail.reminder ?? ''),
productPerformance: String(detail.productPerformance ?? ''),
applicableScenarios: String(detail.applicableScenarios ?? ''),
washingInstructions: String(detail.washingInstructions ?? ''),
specialDescription: String(detail.specialDescription ?? ''),
designExplanation: String(detail.designExplanation ?? ''),
designArea: String(detail.designArea ?? ''),
pictureRequest: String(detail.pictureRequest ?? ''),
sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2),
packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2),
optionsJson: JSON.stringify(detail.options ?? {}, null, 2),
mediaJson: JSON.stringify(detail.media ?? {}, null, 2),
variants: g.variants.map((variant) => ({
sku: variant.sku,
sizeId: String(variant.sizeId ?? ''),
sizeName: String(variant.sizeName ?? ''),
colorId: String(variant.colorId ?? ''),
colorName: String(variant.colorName ?? ''),
colorHex: String(variant.colorHex ?? ''),
imageUrl: String(variant.imageUrl ?? ''),
price: String(variant.price ?? ''),
originalPrice: String(variant.originalPrice ?? ''),
weightG: String(variant.weightG ?? ''),
boxLengthCm: String(variant.boxLengthCm ?? ''),
boxWidthCm: String(variant.boxWidthCm ?? ''),
boxHeightCm: String(variant.boxHeightCm ?? ''),
designDataJson: JSON.stringify(variant.designData ?? {}, null, 2),
enabled: variant.enabled,
})),
}
}
function addCustomVariant() {
customContentForm.value.variants.push({
sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '',
originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true,
})
}
async function openEdit(g: Good) {
editGood.value = g
editMerged.value = (g.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
originalOriginGoodId.value = g.originGoodId
editForm.value = {
id: g.id, goodName: g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || '',
@@ -470,7 +670,10 @@ async function openEdit(g: Good) {
editVisible.value = true
editDetailLoading.value = true
try {
editGood.value = await goodsApi.getGoodById(g.id)
const detail = await goodsApi.getGoodById(g.id)
editGood.value = detail
editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
if (detail.originGood?.isCustom) fillCustomContent(detail)
} catch {
ElMessage.warning('商品详情加载失败,当前显示列表数据')
} finally {
@@ -495,12 +698,15 @@ async function handleSyncOriginDetail(data: any) {
}
async function handleSyncOneDetail() {
if (editIsCustom.value) return
const goodId = editGood.value?.originGood?.sdsGoodId
if (!goodId) return
detailSyncing.value = true
try {
const result = await syncApi.syncOneProductDetail(goodId)
editGood.value = await goodsApi.getGoodById(editGood.value!.id)
const detail = await goodsApi.getGoodById(editGood.value!.id)
editGood.value = detail
editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
await Promise.all([refreshLeftTree(), refreshRightTree()])
} catch (error: any) {
@@ -511,16 +717,88 @@ async function handleSyncOneDetail() {
}
async function handleEditSubmit() {
let customPayload: any = null
if (editIsCustom.value) {
let sizeChart: Record<string, unknown>
let packageSpecs: Record<string, unknown>
let options: Record<string, unknown>
let media: Record<string, unknown>
try {
sizeChart = JSON.parse(customContentForm.value.sizeChartJson)
packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson)
options = JSON.parse(customContentForm.value.optionsJson)
media = JSON.parse(customContentForm.value.mediaJson)
for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson)
} catch {
ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON')
return
}
if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) {
ElMessage.error('SKU 不能为空')
return
}
customPayload = {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
goodPrice: customContentForm.value.goodPrice || null,
detail: {
productCode: customContentForm.value.productCode || null,
englishName: customContentForm.value.englishName || null,
productionCycleHours: customContentForm.value.productionCycleHours ?? null,
minWeightG: customContentForm.value.minWeightG || null,
productionProcess: customContentForm.value.productionProcess || null,
materialDescription: customContentForm.value.materialDescription || null,
blankDesignUrl: customContentForm.value.blankDesignUrl || null,
detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null,
textureName: customContentForm.value.textureName || null,
reminder: customContentForm.value.reminder || null,
productPerformance: customContentForm.value.productPerformance || null,
applicableScenarios: customContentForm.value.applicableScenarios || null,
washingInstructions: customContentForm.value.washingInstructions || null,
specialDescription: customContentForm.value.specialDescription || null,
designExplanation: customContentForm.value.designExplanation || null,
designArea: customContentForm.value.designArea || null,
pictureRequest: customContentForm.value.pictureRequest || null,
sizeChart,
packageSpecs,
options,
media,
},
variants: customContentForm.value.variants.map((variant: any, index: number) => ({
sku: variant.sku.trim(),
sizeId: variant.sizeId || null,
sizeName: variant.sizeName || null,
colorId: variant.colorId || null,
colorName: variant.colorName || null,
colorHex: variant.colorHex || null,
imageUrl: variant.imageUrl || null,
price: variant.price || null,
originalPrice: variant.originalPrice || null,
weightG: variant.weightG || null,
boxLengthCm: variant.boxLengthCm || null,
boxWidthCm: variant.boxWidthCm || null,
boxHeightCm: variant.boxHeightCm || null,
designData: JSON.parse(variant.designDataJson),
enabled: variant.enabled,
sortOrder: index,
})),
}
}
editLoading.value = true
try {
await goodsApi.updateGood(editForm.value.id, {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
originGoodId: editGood.value?.originGoodId !== originalOriginGoodId.value && !editIsCustom.value
? Number(editGood.value!.originGoodId)
: undefined,
mergedOriginGoodIds: editIsCustom.value ? undefined : editMerged.value.map((m) => Number(m.id)),
countryId: Number(editForm.value.countryId),
categoryId: Number(editForm.value.categoryId),
tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功')
editVisible.value = false
refreshLeftTree()
@@ -530,8 +808,9 @@ async function handleEditSubmit() {
} finally { editLoading.value = false }
}
function onEditCascaderChange(val: string[]) {
editForm.value.categoryId = val.length ? val[val.length - 1] : ''
function onEditCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function handleDeleteGood(g: Good) {
@@ -903,13 +1182,13 @@ const tagTreeData = computed<TreeNode[]>(() => {
})),
}))
const ungrouped = allTags.value
const ungrouped: TreeNode[] = allTags.value
.filter((t) => !t.tagGroupId)
.sort((a, b) => a.tagName.localeCompare(b.tagName))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag',
type: 'tag' as const,
label: t.tagName,
}))
@@ -993,7 +1272,7 @@ async function quickCreateCountry(targetForm: () => void) {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const res = await countriesApi.createCountry({ countryName: value.trim() } as any) as any
await countriesApi.createCountry({ countryName: value.trim() } as any)
await reloadCountries()
targetForm()
ElMessage.success('已创建并选中')
@@ -1006,7 +1285,7 @@ async function quickCreateTag(targetForm: () => void) {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '标签名称',
})
if (!value.trim()) return
const res = await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } as any) as any
await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } as any)
await reloadTags()
targetForm()
ElMessage.success('已创建并选中')
@@ -1264,6 +1543,7 @@ onMounted(() => loadAll())
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button>
<el-button size="small" type="success" :icon="Plus" @click="openCustomCreate">新增自定义商品</el-button>
<div class="gv-filter-spacer" />
<el-radio-group v-model="mode" size="small" @change="onModeChange">
@@ -1343,23 +1623,25 @@ onMounted(() => loadAll())
</div>
</div>
<div v-if="data.originGoodName" class="gt-row">
<div class="gt-label">原产品</div>
<div class="gt-label">{{ data.isCustom ? '来源' : '原产品' }}</div>
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
</div>
</div>
</div>
</template>
<span class="good-name">{{ data.goodName }}</span>
<span v-if="data.mergedCount > 1" class="gv-merged-badge">×{{ data.mergedCount }}</span>
</el-tooltip>
</div>
<span class="good-actions" @click.stop>
<el-button size="small" link :icon="Aim" title="定位原产品" @click="locateInRightTree(data.originGoodId)" />
<el-button v-if="!data.isCustom" size="small" link :icon="Aim" title="定位原产品" @click="locateInRightTree(data.originGoodId)" />
<el-button size="small" link :icon="Edit" @click="openEdit(data.raw)" />
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
</span>
</div>
<div v-if="data.country || data.tags?.length || data.originDelisted" class="good-meta">
<span v-if="data.originDelisted" class="good-delisted-badge">下架</span>
<span v-if="data.isCustom" class="good-tag">自定义</span>
<span v-if="data.country" class="good-country">{{ data.country }}</span>
<span
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
@@ -1543,17 +1825,33 @@ onMounted(() => loadAll())
<el-form label-width="80px" style="margin-top: 16px">
<el-form-item label="国家">
<div v-if="mode === 'category' || !configDropTarget" class="select-inline">
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable @change="loadConfigPositions">
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable>
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) configForm.value.countryId = latest.id })" />
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries[allCountries.length - 1]; if (latest) configForm.countryId = latest.id })" />
</div>
<el-tag v-else>{{ allCountries.find(c => c.id === configForm.countryId)?.countryName }}</el-tag>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
</el-form-item>
<el-form-item v-if="configSiblings.length" label="合并同名">
<div class="config-merge-box">
<div class="config-merge-tip">勾选同分类下同名不同工厂/仓库原产品合并为一个商品主源决定价格与详情</div>
<div class="config-merge-primary">
<el-radio-group v-model="configPrimaryId">
<el-radio :value="String(configOG.rawId)">主源{{ configOG.goodName }}</el-radio>
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
</el-radio-group>
</div>
<el-checkbox-group v-model="configChecked">
<el-checkbox v-for="s in configSiblings" :key="s.rawId" :value="String(s.rawId)">
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
</el-checkbox>
</el-checkbox-group>
</div>
</el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
</el-form-item>
@@ -1564,14 +1862,9 @@ onMounted(() => loadAll())
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) configForm.value.tagIds.push(latest.id) })" />
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) configForm.tagIds.push(latest.id) })" />
</div>
</el-form-item>
<el-form-item label="位置">
<el-select v-model="configForm.positionId" clearable placeholder="可选">
<el-option v-for="p in configPositions" :key="p.id" :label="`#${p.indexVal}`" :value="p.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configVisible = false">取消</el-button>
@@ -1579,15 +1872,50 @@ onMounted(() => loadAll())
</template>
</el-dialog>
<!-- Custom Good Modal -->
<el-dialog v-model="customVisible" title="新增自定义商品" width="620px" destroy-on-close>
<el-alert
title="自定义商品不关联 SDS 原产品,名称、图片、价格、详情、尺码、包装和 SKU 均可维护。"
type="info"
:closable="false"
style="margin-bottom:16px"
/>
<el-form label-width="90px">
<el-form-item label="商品名称" required><el-input v-model="customForm.goodName" /></el-form-item>
<el-form-item label="商品图片"><ImageUpload v-model="customForm.goodImage" label="上传图片" /></el-form-item>
<el-form-item label="基础价格"><el-input v-model="customForm.goodPrice" placeholder="例如 28.00" /></el-form-item>
<el-form-item label="国家" required>
<el-select v-model="customForm.countryId" filterable placeholder="请选择国家" style="width:100%">
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="分类" required>
<el-cascader v-model="customForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" style="width:100%" @change="onCustomCascaderChange" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="customForm.tagIds" multiple filterable placeholder="请选择标签" style="width:100%">
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="customForm.goodPriority" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="customVisible = false">取消</el-button>
<el-button type="primary" :loading="customLoading" @click="handleCustomCreate">创建并完善详情</el-button>
</template>
</el-dialog>
<!-- Edit Good Modal (direct edit no separate detail step) -->
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
<!-- Origin product reference -->
<div v-if="editGood?.originGood" class="edit-og-ref">
<el-image v-if="editGood.originGood.goodImage" :src="editGood.originGood.goodImage" fit="cover" class="edit-og-img" />
<div class="edit-og-meta">
<div class="edit-og-label">关联原产品</div>
<div class="edit-og-label">{{ editIsCustom ? '自定义商品' : '关联原产品' }}</div>
<div class="edit-og-name">{{ editGood.originGood.goodName }}</div>
<div class="edit-og-sub">SDS ID: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
<div class="edit-og-sub">{{ editIsCustom ? '自定义 ID' : 'SDS ID' }}: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
<div class="edit-og-status">
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
@@ -1601,6 +1929,7 @@ onMounted(() => loadAll())
</div>
</div>
<el-button
v-if="!editIsCustom"
type="primary"
plain
size="small"
@@ -1609,6 +1938,31 @@ onMounted(() => loadAll())
@click="handleSyncOneDetail"
>同步详情</el-button>
</div>
<div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">
关联原产品主源 + 副源
<el-tooltip content="主源决定价格、详情与上下架;副源变体合并展示。切换主源后旧主源自动转为副源。">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="edit-merged-list">
<div class="edit-merged-item primary">
<span class="edit-merged-tag"></span>
<span>{{ editGood?.originGood?.goodName }}</span>
</div>
<div v-for="m in editMerged" :key="m.id" class="edit-merged-item">
<span class="edit-merged-tag sub"></span>
<span>{{ m.goodName }}</span>
<el-button size="small" link type="primary" @click="promoteMerged(m)">设为主源</el-button>
<el-button size="small" link type="danger" @click="editMerged = editMerged.filter(x => x.id !== m.id)">移除</el-button>
</div>
</div>
<el-select v-model="editMergeSearch" filterable remote :remote-method="(q: string) => editMergeSearch = q"
placeholder="搜索原产品名称以添加副源(用于合并同名商品)" clearable style="width:100%">
<el-option v-for="c in editMergeCandidates" :key="c.rawId" :label="c.goodName" :value="String(c.rawId)"
@click="addEditMerge(c)" />
</el-select>
</div>
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="图片">
@@ -1616,14 +1970,14 @@ onMounted(() => loadAll())
</el-form-item>
<el-form-item label="国家">
<div class="select-inline">
<el-select v-model="editForm.countryId" filterable>
<el-select v-model="editForm.countryId" filterable placeholder="请选择国家">
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) editForm.value.countryId = latest.id })" />
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries[allCountries.length - 1]; if (latest) editForm.countryId = latest.id })" />
</div>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" @change="onEditCascaderChange" />
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
</el-form-item>
<el-form-item label="标签">
<div class="select-inline">
@@ -1632,14 +1986,38 @@ onMounted(() => loadAll())
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) editForm.value.tagIds.push(latest.id) })" />
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags[allTags.length - 1]; if (latest) editForm.tagIds.push(latest.id) })" />
</div>
</el-form-item>
<el-form-item v-if="editIsCustom" label="基础价格">
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
</el-form-item>
</el-form>
<el-tabs v-if="editOriginDetail" class="detail-tabs">
<el-tab-pane label="商品详情">
<el-descriptions :column="2" border size="small">
<el-form v-if="editIsCustom" label-width="100px" class="custom-detail-form">
<el-form-item label="商品编码"><el-input v-model="customContentForm.productCode" /></el-form-item>
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></el-form-item>
<el-form-item label="生产周期"><el-input-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
<el-form-item label="净重"><el-input v-model="customContentForm.minWeightG"><template #append>g</template></el-input></el-form-item>
<el-form-item label="生产工艺"><el-input v-model="customContentForm.productionProcess" type="textarea" /></el-form-item>
<el-form-item label="材质"><el-input v-model="customContentForm.materialDescription" type="textarea" /></el-form-item>
<el-form-item label="空白设计图"><el-input v-model="customContentForm.blankDesignUrl" /></el-form-item>
<el-form-item label="详情视频"><el-input v-model="customContentForm.detailsPageVideoUrl" /></el-form-item>
<el-form-item label="面料名称"><el-input v-model="customContentForm.textureName" /></el-form-item>
<el-form-item label="温馨提示"><el-input v-model="customContentForm.reminder" type="textarea" /></el-form-item>
<el-form-item label="产品性能"><el-input v-model="customContentForm.productPerformance" type="textarea" /></el-form-item>
<el-form-item label="适用场景"><el-input v-model="customContentForm.applicableScenarios" type="textarea" /></el-form-item>
<el-form-item label="洗涤说明"><el-input v-model="customContentForm.washingInstructions" type="textarea" /></el-form-item>
<el-form-item label="特殊说明"><el-input v-model="customContentForm.specialDescription" type="textarea" /></el-form-item>
<el-form-item label="设计说明"><el-input v-model="customContentForm.designExplanation" type="textarea" /></el-form-item>
<el-form-item label="设计区域"><el-input v-model="customContentForm.designArea" /></el-form-item>
<el-form-item label="图片要求"><el-input v-model="customContentForm.pictureRequest" type="textarea" /></el-form-item>
<el-form-item label="商品选项 JSON"><el-input v-model="customContentForm.optionsJson" type="textarea" :rows="6" /></el-form-item>
<el-form-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
</el-form>
<el-descriptions v-else :column="2" border size="small">
<el-descriptions-item label="商品编码">{{ editOriginDetail.productCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="英文名称">{{ editOriginDetail.englishName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生产周期">{{ editOriginDetail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
@@ -1649,7 +2027,8 @@ onMounted(() => loadAll())
</el-descriptions>
</el-tab-pane>
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
<el-table :data="editSizeRows" border max-height="320">
<el-input v-if="editIsCustom" v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
<el-table v-else :data="editSizeRows" border max-height="320">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in editSizeColumns"
@@ -1661,7 +2040,8 @@ onMounted(() => loadAll())
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
<el-table :data="editPackageRows" border max-height="320">
<el-input v-if="editIsCustom" v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
<el-table v-else :data="editPackageRows" border max-height="320">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="160">
<template #default="{ row }">
@@ -1673,7 +2053,28 @@ onMounted(() => loadAll())
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${editVariants.length})`">
<el-table :data="editVariants" border max-height="320">
<template v-if="editIsCustom">
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
<el-table :data="customContentForm.variants" border max-height="320">
<el-table-column label="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
<el-table-column label="尺码 ID" width="120"><template #default="{ row }"><el-input v-model="row.sizeId" /></template></el-table-column>
<el-table-column label="尺码" width="120"><template #default="{ row }"><el-input v-model="row.sizeName" /></template></el-table-column>
<el-table-column label="颜色 ID" width="120"><template #default="{ row }"><el-input v-model="row.colorId" /></template></el-table-column>
<el-table-column label="颜色" width="120"><template #default="{ row }"><el-input v-model="row.colorName" /></template></el-table-column>
<el-table-column label="色值" width="120"><template #default="{ row }"><el-input v-model="row.colorHex" placeholder="#FFFFFF" /></template></el-table-column>
<el-table-column label="图片" min-width="180"><template #default="{ row }"><el-input v-model="row.imageUrl" /></template></el-table-column>
<el-table-column label="价格" width="120"><template #default="{ row }"><el-input v-model="row.price" /></template></el-table-column>
<el-table-column label="原价" width="120"><template #default="{ row }"><el-input v-model="row.originalPrice" /></template></el-table-column>
<el-table-column label="重量(g)" width="120"><template #default="{ row }"><el-input v-model="row.weightG" /></template></el-table-column>
<el-table-column label="包装长(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxLengthCm" /></template></el-table-column>
<el-table-column label="包装宽(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxWidthCm" /></template></el-table-column>
<el-table-column label="包装高(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxHeightCm" /></template></el-table-column>
<el-table-column label="设计数据 JSON" min-width="220"><template #default="{ row }"><el-input v-model="row.designDataJson" type="textarea" :rows="2" /></template></el-table-column>
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
</el-table>
</template>
<el-table v-else :data="editVariants" border max-height="320">
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
<el-table-column prop="sizeName" label="尺码" width="90" />
<el-table-column prop="colorName" label="颜色" width="100" />
@@ -1908,10 +2309,21 @@ onMounted(() => loadAll())
}
.good-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.good-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
.good-name-col { flex: 1; min-width: 0; overflow: hidden; }
.good-name {
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13px; line-height: 1.4; cursor: pointer;
max-width: calc(100% - 40px);
}
.good-name-col { flex: 1; min-width: 0; overflow: hidden; }
.gv-merged-badge {
margin-left: 4px;
padding: 0 5px;
border-radius: 8px;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-size: 11px;
vertical-align: middle;
flex-shrink: 0;
}
.good-actions {
display: flex; gap: 2px; flex-shrink: 0;
@@ -2005,12 +2417,35 @@ onMounted(() => loadAll())
.edit-og-sub { color: #909399; font-size: 12px; margin-top: 2px; }
.edit-og-meta { flex: 1; min-width: 0; }
.edit-og-status { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 6px; color: #909399; font-size: 12px; }
.edit-merged-box { margin-top: 12px; }
.edit-merged-title {
display: flex; align-items: center; gap: 4px;
font-size: 12px; color: #909399; margin-bottom: 8px;
}
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.edit-merged-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px;
}
.edit-merged-item .el-button { margin-left: auto; }
.edit-merged-tag {
padding: 0 5px; border-radius: 4px; font-size: 11px; line-height: 18px;
background: var(--el-color-primary); color: #fff; flex-shrink: 0;
}
.edit-merged-tag.sub { background: var(--el-color-info); }
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
/* Config modal */
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
.config-og-name { font-weight: 600; font-size: 14px; }
.config-og-meta { color: #909399; font-size: 12px; margin-top: 2px; }
.config-merge-box { width: 100%; }
.config-merge-tip { color: #909399; font-size: 12px; margin-bottom: 8px; line-height: 1.5; }
.config-merge-primary {
padding: 8px 10px; background: #f5f7fa; border-radius: 6px; margin-bottom: 8px;
}
.config-merge-primary .el-radio-group { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.config-merge-box .el-checkbox-group { display: flex; flex-direction: column; align-items: flex-start; max-height: 160px; overflow-y: auto; }
/* Tag mgmt */
.tag-mgmt { max-height: 400px; overflow-y: auto; }
@@ -8,7 +8,6 @@ import type {
UpdatePositionRequest,
PositionFilter,
Country,
Category,
CategoryTree,
} from '@/types'
import { positionsApi } from '@/api/positions'
@@ -41,7 +40,7 @@ async function loadLookups() {
async function fetchList() {
loading.value = true
try {
const res = await positionsApi.getPositionsList(filter)
await positionsApi.getPositionsList(filter)
const data = await positionsApi.getPositionsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
@@ -88,8 +87,6 @@ const dialogLoading = ref(false)
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
indexVal: 0,
countryId: '',
categoryId: '',
})
const dialogRules = {
@@ -218,17 +215,17 @@ onMounted(async () => {
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
<el-table-column label="国家" min-width="160">
<template #default="{ row }: { row: Position }">
<template #default="{ row }: any">
{{ row.country?.countryName || '-' }}
</template>
</el-table-column>
<el-table-column label="分类" min-width="200">
<template #default="{ row }: { row: Position }">
<template #default="{ row }: any">
{{ getCategoryName(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Position }">
<template #default="{ row }: any">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
+4 -4
View File
@@ -28,7 +28,7 @@ async function refreshLogs() {
function syncTypeLabel(type: SyncType): string {
if (type === 'CATEGORIES') return '分类'
if (type === 'PRODUCT_DETAILS') return '品详情'
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
return '商品列表'
}
@@ -70,7 +70,7 @@ async function doSync(type: SyncType) {
const label = syncTypeLabel(type)
try {
await ElMessageBox.confirm(
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
'确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
)
@@ -136,7 +136,7 @@ onUnmounted(() => {
</div>
<div>
<h2 class="sync-action-title">数据同步</h2>
<p class="sync-action-desc">从上游 SDS 系统拉取最新分类和产品数据并同步到本地数据库</p>
<p class="sync-action-desc">分类和产品每小时自动同步全部 SDS 原产品详情每天 03:30 自动同步也可手动执行</p>
</div>
</div>
<div class="sync-action-buttons">
@@ -166,7 +166,7 @@ onUnmounted(() => {
:icon="Refresh"
@click="handleSyncProductDetails"
>
同步品详情
同步全部原产品详情
</el-button>
</div>
</div>
+6 -7
View File
@@ -26,7 +26,7 @@ const filter = reactive<Required<TagFilter>>({
async function fetchList() {
loading.value = true
try {
const res = await tagsApi.getTagsList(filter)
await tagsApi.getTagsList(filter)
const data = await tagsApi.getTagsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
@@ -62,7 +62,6 @@ const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: numbe
tagColor: '#ff6800',
tagFontColor: '#ffffff',
timing: '',
tagGroupId: null,
})
const dialogRules = {
@@ -171,7 +170,7 @@ onMounted(() => {
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="预览" width="120">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<span
v-if="row.tagColor"
class="tag-preview"
@@ -182,7 +181,7 @@ onMounted(() => {
</el-table-column>
<el-table-column prop="tagName" label="名称" min-width="200" />
<el-table-column label="背景色" width="140">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagColor || '-' }}</span>
@@ -190,7 +189,7 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="字体色" width="140">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
@@ -198,14 +197,14 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="所属分组" min-width="140">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
<span v-else style="color: #999;">未分组</span>
</template>
</el-table-column>
<el-table-column prop="timing" label="定时" min-width="120" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Tag }">
<template #default="{ row }: any">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
+1
View File
@@ -7,6 +7,7 @@ import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
base: '/admin/',
plugins: [
vue(),
AutoImport({
+20
View File
@@ -0,0 +1,20 @@
# Prisma connection string (PostgreSQL)
DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website
# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
# Must be at least 32 characters.
JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET
# Access token lifetime (e.g. 30m, 12h); short-lived, rotated via /auth/refresh
TOKEN_EXPIRES_IN=30m
# Refresh token lifetime (HttpOnly cookie)
REFRESH_TOKEN_EXPIRES_IN=7d
# Comma-separated list of allowed CORS origins (leave empty to disable CORS)
CORS_ORIGINS=http://localhost:5173
# Global rate limit per minute (per IP)
THROTTLE_LIMIT=120
PORT=3001
+4
View File
@@ -34,13 +34,16 @@
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.8.0",
"@types/multer": "^2.2.0",
"axios": "^1.6.5",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.7",
"express": "^4.21.0",
"helmet": "^8.3.0",
"multer": "^2.2.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -52,6 +55,7 @@
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "origin_goods" ADD COLUMN "delisted" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,22 @@
-- Sync database with schema.prisma (missing columns/table from earlier iterations)
-- AlterTable
ALTER TABLE "goods" ADD COLUMN "good_image" TEXT;
-- AlterTable
ALTER TABLE "tags" ADD COLUMN "tag_font_color" TEXT;
-- CreateTable
CREATE TABLE "good_tags" (
"good_id" BIGINT NOT NULL,
"tag_id" BIGINT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "good_tags_pkey" PRIMARY KEY ("good_id","tag_id")
);
-- CreateIndex
CREATE INDEX "good_tags_tag_id_idx" ON "good_tags"("tag_id");
-- AddForeignKey
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,6 @@
CREATE TYPE "OriginGoodSource" AS ENUM ('SDS', 'CUSTOM');
ALTER TABLE "origin_goods"
ADD COLUMN "source" "OriginGoodSource" NOT NULL DEFAULT 'SDS';
CREATE INDEX "origin_goods_source_idx" ON "origin_goods"("source");
@@ -0,0 +1,8 @@
-- Create enum for user roles
CREATE TYPE "Role" AS ENUM ('ADMIN');
-- Add role column, existing users become ADMIN
ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'ADMIN';
-- Token version for JWT revocation (logout bumps it)
ALTER TABLE "users" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "good_origin_goods" (
"good_id" BIGINT NOT NULL,
"origin_good_id" BIGINT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "good_origin_goods_pkey" PRIMARY KEY ("good_id","origin_good_id")
);
-- CreateIndex
CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id");
-- AddForeignKey
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
+32
View File
@@ -6,6 +6,7 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
@@ -14,6 +15,11 @@ datasource db {
}
// ---------- Origin Goods ----------
enum OriginGoodSource {
SDS
CUSTOM
}
model OriginGood {
id BigInt @id @default(autoincrement()) @map("origin_good_id")
sdsGoodId String @unique @map("sds_good_id")
@@ -23,14 +29,17 @@ model OriginGood {
goodImage String? @map("good_image")
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
delisted Boolean @default(false)
source OriginGoodSource @default(SDS)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
goods Good[]
detail OriginGoodDetail?
variants OriginGoodVariant[]
mergedIntoGoods GoodOriginGood[]
@@index([sdsCategoryId])
@@index([source])
@@map("origin_goods")
}
@@ -207,6 +216,7 @@ model Good {
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
goodTags GoodTag[]
mergedOriginGoods GoodOriginGood[]
@@index([originGoodId])
@@index([countryId])
@@ -233,11 +243,33 @@ model GoodTag {
@@map("good_tags")
}
// ---------- Good-OriginGood Junction (merged secondary sources, M:N) ----------
// Primary source stays on goods.origin_good_id and is NOT stored here.
model GoodOriginGood {
goodId BigInt @map("good_id")
originGoodId BigInt @map("origin_good_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([goodId, originGoodId])
@@index([originGoodId])
@@map("good_origin_goods")
}
// ---------- Users (admin authentication) ----------
enum Role {
ADMIN
}
model User {
id BigInt @id @default(autoincrement())
username String @unique
passwordHash String @map("password_hash")
role Role @default(ADMIN)
// Bumped on logout / revocation; JWTs carrying an older version are rejected.
tokenVersion Int @default(0) @map("token_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
+65
View File
@@ -0,0 +1,65 @@
/**
* Export all application tables from the local (source) database to a JSON
* file, preserving column types for the matching import-data.mjs script.
*
* Usage (from apps/api, against the local DB in .env):
* node scripts/export-data.mjs <output.json>
*/
import { PrismaClient } from '@prisma/client';
import { writeFileSync } from 'node:fs';
const TABLES = [
'users',
'countries',
'categories',
'tag_groups',
'tags',
'positions',
'origin_goods',
'origin_good_variants',
'origin_good_details',
'goods',
'good_tags',
'sync_logs',
];
const prisma = new PrismaClient();
// Serialize values losslessly; import side uses information_schema to restore types.
function serialize(value) {
if (value === null || value === undefined) return null;
if (typeof value === 'bigint') return value.toString();
if (value instanceof Date) return value.toISOString();
if (typeof value === 'object' && Buffer.isBuffer(value)) return value.toString('base64');
if (typeof value === 'object') return JSON.stringify(value); // jsonb
return value;
}
async function main() {
const out = process.argv[2];
if (!out) {
console.error('Usage: node scripts/export-data.mjs <output.json>');
process.exit(1);
}
const dump = { exportedAt: new Date().toISOString(), tables: {} };
for (const table of TABLES) {
const rows = await prisma.$queryRawUnsafe(`SELECT * FROM "${table}"`);
dump.tables[table] = rows.map((row) => {
const o = {};
for (const [k, v] of Object.entries(row)) o[k] = serialize(v);
return o;
});
console.log(`${table}: ${rows.length} rows`);
}
writeFileSync(out, JSON.stringify(dump));
console.log(`Wrote ${out}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+106
View File
@@ -0,0 +1,106 @@
/**
* Import a dump produced by export-data.mjs into the current database.
* Tables are truncated first (order-independent via session_replication_role)
* and columns are cast back to their real types using information_schema.
*
* Usage (inside the api container):
* node scripts/import-data.mjs <dump.json>
*/
import { PrismaClient, Prisma } from '@prisma/client';
import { readFileSync } from 'node:fs';
const prisma = new PrismaClient();
function toLiteral(value, udtName) {
if (value === null) return Prisma.sql`NULL`;
const target = Prisma.raw(`"${udtName}"`);
switch (udtName) {
case 'int2':
case 'int4':
case 'int8':
return Prisma.sql`${BigInt(value)}::${target}`;
case 'float4':
case 'float8':
case 'numeric':
return Prisma.sql`${Number(value)}::${target}`;
case 'bool':
return Prisma.sql`${!!value}::${target}`;
case 'timestamptz':
case 'timestamp':
return Prisma.sql`${new Date(value).toISOString()}::${target}`;
case 'date':
return Prisma.sql`${String(value)}::${target}`;
case 'jsonb':
case 'json':
return Prisma.sql`${typeof value === 'string' ? value : JSON.stringify(value)}::${target}`;
case 'bytea':
return Prisma.sql`${Buffer.from(value, 'base64')}::bytea`;
default:
// text, varchar, enums and anything else: pass as text and cast
return Prisma.sql`${String(value)}::${target}`;
}
}
async function main() {
const file = process.argv[2];
if (!file) {
console.error('Usage: node scripts/import-data.mjs <dump.json>');
process.exit(1);
}
const dump = JSON.parse(readFileSync(file, 'utf8'));
// Suspend FK checks during bulk load (postgres superuser not required for
// session_replication_role in the compose postgres where app user owns db).
await prisma.$executeRawUnsafe(`SET session_replication_role = replica`);
const summary = {};
for (const [table, rows] of Object.entries(dump.tables)) {
if (rows.length === 0) {
summary[table] = 0;
continue;
}
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
const colTypes = {};
const info = await prisma.$queryRawUnsafe(
`SELECT column_name, udt_name FROM information_schema.columns WHERE table_name = '${table}'`,
);
for (const c of info) colTypes[c.column_name] = c.udt_name;
const columns = Object.keys(rows[0]);
const colList = Prisma.raw(columns.map((c) => `"${c}"`).join(', '));
const CHUNK = 200;
for (let i = 0; i < rows.length; i += CHUNK) {
const tuples = rows.slice(i, i + CHUNK).map(
(r) =>
Prisma.sql`(${Prisma.join(
columns.map((c) => toLiteral(r[c], colTypes[c])),
)})`,
);
await prisma.$executeRaw(
Prisma.sql`INSERT INTO ${Prisma.raw(`"${table}"`)} (${colList}) VALUES ${Prisma.join(tuples)}`,
);
}
// Keep sequences ahead of imported serial ids
// Keep serial sequences ahead of imported ids
const idCol = columns.find(
(c) => c === 'id' || c === `${table.replace(/s$/, '')}_id`,
);
if (idCol) {
await prisma.$executeRawUnsafe(
`SELECT setval(pg_get_serial_sequence('"${table}"', '${idCol}'), COALESCE((SELECT MAX("${idCol}") FROM "${table}"), 1))`,
);
}
summary[table] = rows.length;
}
await prisma.$executeRawUnsafe(`SET session_replication_role = DEFAULT`);
console.log('Imported:', JSON.stringify(summary, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+42
View File
@@ -0,0 +1,42 @@
/**
* Set the admin credentials for the production deployment:
* rename/disable any existing admin and create/update user `inkreach`.
*
* Usage (inside the api container): node scripts/set-admin.mjs
* Reads NEW_ADMIN_USER / NEW_ADMIN_PASSWORD from env.
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const username = process.env.NEW_ADMIN_USER;
const password = process.env.NEW_ADMIN_PASSWORD;
if (!username || !password) {
console.error('NEW_ADMIN_USER / NEW_ADMIN_PASSWORD must be set');
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 10);
await prisma.user.upsert({
where: { username },
create: { username, passwordHash },
update: { passwordHash, tokenVersion: { increment: 1 } },
});
// Remove every other admin so only `inkreach` can sign in.
const others = await prisma.user.deleteMany({
where: { username: { not: username } },
});
const total = await prisma.user.count();
console.log(`Admin '${username}' set. Demoted ${others.count} other user(s). Total users: ${total}.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+23
View File
@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { RolesGuard } from './auth/guards/roles.guard';
import { CountriesModule } from './countries/countries.module';
import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module';
@@ -18,6 +21,14 @@ import { UploadModule } from './upload/upload.module';
ConfigModule.forRoot({
isGlobal: true,
}),
// Global rate limiting: 120 req/min per IP. Stricter limits are set
// per-endpoint with @Throttle (auth, upload).
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: Number(process.env.THROTTLE_LIMIT ?? 120),
},
]),
PrismaModule,
AuthModule,
CountriesModule,
@@ -31,5 +42,17 @@ import { UploadModule } from './upload/upload.module';
PublicModule,
UploadModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
// Enforces the ADMIN role on every authenticated route unless the
// route widens access with @Roles(...).
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
+108 -13
View File
@@ -1,16 +1,28 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import {
LoginResponseDto,
UserPublicDto,
} from './dto/auth-response.dto';
import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
import type { AuthenticatedUser } from './strategies/jwt.strategy';
const ACCESS_TOKEN_COOKIE = 'ir_at';
const REFRESH_TOKEN_COOKIE = 'ir_rt';
const isProd = process.env.NODE_ENV === 'production';
@ApiTags('auth')
@Controller('auth')
@@ -19,19 +31,102 @@ export class AuthController {
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new admin user' })
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Register the first admin user (bootstrap only)' })
@ApiResponse({ status: 201, type: UserPublicDto })
@ApiResponse({ status: 409, description: 'Username already exists' })
@ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' })
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login and obtain a JWT' })
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Login and obtain access + refresh tokens' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
): Promise<LoginResponseDto> {
const result = await this.authService.login(dto);
// HttpOnly cookies are the primary session channel for the admin SPA
// (XSS cannot read them). The access token is also returned in the
// body for non-browser API clients.
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
});
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
// Only ever sent to /auth/refresh and /auth/logout
path: '/auth',
});
// The refresh token deliberately stays HttpOnly-only.
return {
accessToken: result.accessToken,
user: result.user,
} as unknown as LoginResponseDto;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: 'Rotate the refresh token cookie' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<LoginResponseDto> {
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
if (!token) {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
throw new UnauthorizedException('Missing refresh token');
}
const result = await this.authService.refresh(token);
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
});
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/auth',
});
return {
accessToken: result.accessToken,
user: result.user,
} as unknown as LoginResponseDto;
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Current authenticated user' })
@ApiResponse({ status: 200, type: UserPublicDto })
me(@Req() req: Request & { user: AuthenticatedUser }): Promise<UserPublicDto> {
return this.authService.me(req.user.id) as unknown as Promise<UserPublicDto>;
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Revoke all tokens of the current user' })
async logout(
@Req() req: Request & { user: AuthenticatedUser },
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true }> {
await this.authService.logout(req.user.id);
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
return { success: true };
}
}
+6 -1
View File
@@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy';
if (!secret) {
throw new Error('JWT_SECRET must be configured');
}
if (secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters (use a strong random value)');
}
return {
secret,
signOptions: { expiresIn: '7d' },
signOptions: {
expiresIn: config.get<string>('TOKEN_EXPIRES_IN') ?? '7d',
},
};
},
}),
+119 -54
View File
@@ -1,102 +1,167 @@
import { Test } from '@nestjs/testing';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
describe('AuthService', () => {
let service: AuthService;
let prisma: PrismaService;
const createdUsernames: string[] = [];
let jwt: JwtService;
let prisma: {
user: {
count: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
const HASH = bcrypt.hashSync('plain-pwd', 10);
const dbUser = {
id: 1n,
username: 'alice',
passwordHash: HASH,
role: 'ADMIN' as const,
tokenVersion: 0,
createdAt: new Date('2026-01-01T00:00:00Z'),
};
beforeAll(async () => {
prisma = {
user: {
count: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({
secret: 'test-secret',
secret: 'a'.repeat(32),
signOptions: { expiresIn: '1h' },
}),
],
providers: [AuthService, PrismaService],
providers: [AuthService, { provide: PrismaService, useValue: prisma }],
}).compile();
service = moduleRef.get(AuthService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
jwt = moduleRef.get(JwtService);
});
afterAll(async () => {
// Cleanup created test users
if (createdUsernames.length) {
await prisma.user.deleteMany({
where: { username: { in: createdUsernames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
beforeEach(() => {
jest.clearAllMocks();
});
describe('register', () => {
it('creates a new user and stores a hashed password', async () => {
const username = `test_reg_${Date.now()}`;
createdUsernames.push(username);
it('creates the first user with a hashed password', async () => {
prisma.user.count.mockResolvedValueOnce(0);
prisma.user.findUnique.mockResolvedValueOnce(null);
prisma.user.create.mockResolvedValueOnce(dbUser);
const user = await service.register({ username, password: 'plain-pwd' });
const user = await service.register({ username: 'alice', password: 'plain-pwd' });
expect(user.username).toBe(username);
expect(user.id).toBeTruthy();
expect(user.username).toBe('alice');
const created = prisma.user.create.mock.calls[0][0].data;
expect(created.passwordHash).not.toBe('plain-pwd');
await expect(bcrypt.compare('plain-pwd', created.passwordHash)).resolves.toBe(true);
});
const stored = await prisma.user.findUnique({ where: { username } });
expect(stored).not.toBeNull();
expect(stored?.passwordHash).not.toBe('plain-pwd');
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
expect(matches).toBe(true);
it('refuses registration once a user exists (bootstrap lock)', async () => {
prisma.user.count.mockResolvedValueOnce(1);
await expect(
service.register({ username: 'mallory', password: 'evil-pwd' }),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('throws ConflictException for duplicate usernames', async () => {
const username = `test_dup_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'pwd1234' });
prisma.user.count.mockResolvedValueOnce(0);
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
await expect(
service.register({ username, password: 'pwd5678' }),
service.register({ username: 'alice', password: 'pwd5678' }),
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('login', () => {
it('returns an access token for valid credentials', async () => {
const username = `test_login_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'correct-pwd' });
it('returns access + refresh tokens with tokenVersion and type', async () => {
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
const result = await service.login({ username: 'alice', password: 'plain-pwd' });
const result = await service.login({ username, password: 'correct-pwd' });
expect(result.accessToken).toEqual(expect.any(String));
const parts = result.accessToken.split('.');
expect(parts.length).toBe(3);
expect(result.user.username).toBe(username);
expect(result.user.username).toBe('alice');
const access = jwt.decode(result.accessToken) as Record<string, unknown>;
expect(access.typ).toBe('access');
expect(access.tv).toBe(0);
const refresh = jwt.decode(result.refreshToken) as Record<string, unknown>;
expect(refresh.typ).toBe('refresh');
expect(refresh.tv).toBe(0);
});
it('throws UnauthorizedException for wrong password', async () => {
const username = `test_wrong_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'right-pwd' });
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
await expect(
service.login({ username, password: 'wrong-pwd' }),
service.login({ username: 'alice', password: 'wrong-pwd' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws UnauthorizedException for unknown user', async () => {
prisma.user.findUnique.mockResolvedValueOnce(null);
await expect(
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
service.login({ username: 'no-such-user', password: 'whatever' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
describe('refresh', () => {
it('rotates a valid refresh token', async () => {
const refreshToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'refresh',
});
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
const result = await service.refresh(refreshToken);
expect(result.user.username).toBe('alice');
expect(result.accessToken).not.toBe(refreshToken);
});
it('rejects access tokens used as refresh tokens', async () => {
const accessToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'access',
});
await expect(service.refresh(accessToken)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects refresh tokens with a stale tokenVersion (revoked)', async () => {
const refreshToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'refresh',
});
// User logged out elsewhere: tokenVersion bumped to 1
prisma.user.findUnique.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
});
});
describe('logout', () => {
it('bumps tokenVersion to revoke all tokens', async () => {
prisma.user.update.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
await service.logout(1n);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 1n },
data: { tokenVersion: { increment: 1 } },
});
});
});
});
+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,
@@ -15,6 +15,13 @@ export class BatchCreateItemDto {
@Min(1)
originGoodId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
mergedOriginGoodIds?: number[];
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@@ -20,6 +20,13 @@ export class CreateGoodDto {
@Min(1)
originGoodId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
mergedOriginGoodIds?: number[];
@ApiProperty()
@IsInt()
@Min(1)
+236
View File
@@ -0,0 +1,236 @@
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsInt,
IsNumberString,
IsObject,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import { CreateGoodDto } from './create-good.dto';
export class CustomGoodDetailDto {
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
productCode?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
englishName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
blankDesignUrl?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
detailsPageVideoUrl?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
textureName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(0)
productionCycleHours?: number | null;
@ApiProperty({ required: false, nullable: true, example: '208.000' })
@IsOptional()
@IsNumberString()
minWeightG?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
productionProcess?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
materialDescription?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
reminder?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
productPerformance?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
applicableScenarios?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
washingInstructions?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
specialDescription?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
designExplanation?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
designArea?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
pictureRequest?: string | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
sizeChart?: Record<string, unknown> | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
packageSpecs?: Record<string, unknown> | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
options?: Record<string, unknown> | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
media?: Record<string, unknown> | null;
}
export class CustomGoodVariantDto {
@ApiProperty()
@IsString()
sku!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorHex?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
imageUrl?: string | null;
@ApiProperty({ required: false, nullable: true, example: '28.00' })
@IsOptional()
@IsNumberString()
price?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
originalPrice?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
weightG?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
boxLengthCm?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
boxWidthCm?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
boxHeightCm?: string | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
designData?: Record<string, unknown> | null;
@ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean()
enabled?: boolean;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
}
export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [
'originGoodId',
] as const) {
@ApiProperty({ required: false, nullable: true, example: '28.00' })
@IsOptional()
@IsNumberString()
goodPrice?: string | null;
@ApiProperty({ required: false, type: CustomGoodDetailDto })
@IsOptional()
@ValidateNested()
@Type(() => CustomGoodDetailDto)
detail?: CustomGoodDetailDto;
@ApiProperty({ required: false, type: [CustomGoodVariantDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CustomGoodVariantDto)
variants?: CustomGoodVariantDto[];
}
export class UpdateCustomGoodContentDto extends PartialType(
OmitType(CreateCustomGoodDto, [
'countryId',
'categoryId',
'tagIds',
'positionId',
'goodPriority',
] as const),
) {}
+77 -6
View File
@@ -9,6 +9,7 @@ export interface GoodRelations {
originGood?: {
id: bigint;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
goodName: string | null;
goodImage: string | null;
goodPrice: unknown;
@@ -32,6 +33,31 @@ export interface GoodRelations {
_count?: { variants: number };
} | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
mergedOriginGoods?: Array<{
originGood: {
id: bigint;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
goodName: string | null;
goodImage: string | null;
goodPrice: unknown;
detail?: { syncedAt: Date } | Record<string, unknown> | null;
variants?: Array<{ [key: string]: unknown }>;
_count?: { variants: number };
};
}>;
}
export interface MergedOriginGoodSummary {
id: string;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
isCustom: boolean;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
hasDetail: boolean;
variantCount: number;
}
export class GoodDto {
@@ -80,6 +106,9 @@ export class GoodDto {
@ApiProperty({ required: false, type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
@ApiProperty({ required: false, type: Array })
mergedOriginGoods!: MergedOriginGoodSummary[];
@ApiProperty({ required: false, nullable: true })
position?: { id: string; indexVal: number } | null;
@@ -87,6 +116,8 @@ export class GoodDto {
originGood?: {
id: string;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
isCustom: boolean;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
@@ -144,6 +175,21 @@ export class GoodDto {
tagFontColor: gt.tag.tagFontColor,
}))
: [],
mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({
id: m.originGood.id.toString(),
sdsGoodId: m.originGood.sdsGoodId,
source: m.originGood.source,
isCustom: m.originGood.source === 'CUSTOM',
goodName: m.originGood.goodName,
goodImage: m.originGood.goodImage,
goodPrice:
m.originGood.goodPrice === null || m.originGood.goodPrice === undefined
? null
: (m.originGood.goodPrice as { toString(): string }).toString(),
hasDetail: Boolean(m.originGood.detail),
variantCount:
m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0,
})),
position: rel.position
? {
id: rel.position.id.toString(),
@@ -154,6 +200,8 @@ export class GoodDto {
? {
id: rel.originGood.id.toString(),
sdsGoodId: rel.originGood.sdsGoodId,
source: rel.originGood.source,
isCustom: rel.originGood.source === 'CUSTOM',
goodName: rel.originGood.goodName,
goodImage: rel.originGood.goodImage,
goodPrice:
@@ -189,16 +237,39 @@ export class GoodDetailDto extends GoodDto {
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
const base = GoodDto.from(good, rel);
const detail = rel.originGood?.detail;
return {
...base,
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
variants: (rel.originGood?.variants ?? []).map((variant) => ({
const toAnnotated = (
variant: Record<string, unknown>,
originGoodId: string,
originGoodName: string | null,
) => ({
...variant,
price:
variant.price === null || variant.price === undefined
? null
: (variant.price as { toString(): string }).toString(),
})),
: (variant.price as unknown as { toString(): string }).toString(),
originGoodId,
originGoodName,
});
const primaryId = good.originGoodId.toString();
const primaryName = rel.originGood?.goodName ?? null;
const mergedVariants = [
...(rel.originGood?.variants ?? []).map((variant) =>
toAnnotated(variant as Record<string, unknown>, primaryId, primaryName),
),
...(rel.mergedOriginGoods ?? []).flatMap((m) =>
(m.originGood.variants ?? []).map((variant) =>
toAnnotated(
variant,
m.originGood.id.toString(),
m.originGood.goodName,
),
),
),
];
return {
...base,
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
variants: mergedVariants,
};
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ export class QueryGoodDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
@Max(1000)
pageSize: number = 20;
@ApiProperty({ required: false })
@@ -19,6 +19,13 @@ export class UpdateGoodDto {
@Min(1)
originGoodId?: number;
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID 全量覆盖,不含主源' })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
mergedOriginGoodIds?: number[];
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
+19
View File
@@ -22,6 +22,10 @@ import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
import {
CreateCustomGoodDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
@ApiTags('goods')
@ApiBearerAuth()
@@ -54,6 +58,21 @@ export class GoodsController {
return this.service.batchCreate(dto);
}
@Post('custom')
@ApiOperation({ summary: 'Create a fully editable custom product' })
createCustom(@Body() dto: CreateCustomGoodDto) {
return this.service.createCustom(dto);
}
@Patch(':id/custom-content')
@ApiOperation({ summary: 'Update editable content for a custom product' })
updateCustomContent(
@Param('id', ParseIntPipe) id: string,
@Body() dto: UpdateCustomGoodContentDto,
) {
return this.service.updateCustomContent(BigInt(id), dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get one good with relations' })
findOne(@Param('id', ParseIntPipe) id: string) {
+174
View File
@@ -119,6 +119,51 @@ describe('GoodsService', () => {
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
});
it('creates, edits, and removes a fully editable custom good', async () => {
const created = await service.createCustom({
goodName: `Goods Test ${stamp} custom`,
goodImage: 'https://example.com/custom.png',
goodPrice: '29.90',
countryId: Number(countryId),
categoryId: Number(categoryId),
tagIds: [Number(tagId)],
detail: {
productCode: `CUSTOM-${stamp}`,
materialDescription: 'Cotton',
sizeChart: { columns: [], rows: [] },
packageSpecs: { rows: [] },
},
variants: [
{ sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' },
],
});
expect(created.originGood?.source).toBe('CUSTOM');
expect(created.originGood?.isCustom).toBe(true);
expect(created.originGood?.goodPrice).toBe('29.9');
expect(created.variants).toHaveLength(1);
const updated = await service.updateCustomContent(BigInt(created.id), {
goodName: `Goods Test ${stamp} custom edited`,
goodPrice: '39.90',
detail: { materialDescription: 'Organic cotton' },
variants: [
{ sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' },
],
});
expect(updated.goodName).toContain('custom edited');
expect(updated.originGood?.goodPrice).toBe('39.9');
expect(updated.originDetail?.materialDescription).toBe('Organic cotton');
expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`);
expect(updated.variants[0]?.sizeName).toBe('M');
const customOriginId = BigInt(updated.originGoodId);
await service.remove(BigInt(updated.id));
await expect(
prisma.originGood.findUnique({ where: { id: customOriginId } }),
).resolves.toBeNull();
});
it('filters by countryId, tagId, positionId and keyword', async () => {
const result = await service.findAll({
page: 1,
@@ -216,6 +261,135 @@ describe('GoodsService', () => {
expect(after.total).toBe(before.total);
});
describe('merged origin goods', () => {
it('creates a good with merged origin goods and reads them back', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} merged`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
[originGoodIds[2].toString(), originGoodIds[3].toString()].sort(),
);
const fetched = await service.findOne(BigInt(created.id));
expect(fetched.mergedOriginGoods.length).toBe(2);
});
it('rejects mergedOriginGoodIds containing the primary', async () => {
await expect(
service.create({
goodName: `Goods Test ${stamp} bad-primary`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[1])],
countryId: Number(countryId),
categoryId: Number(categoryId),
}),
).rejects.toThrow(BadRequestException);
});
it('rejects mergedOriginGoodIds that do not exist', async () => {
await expect(
service.create({
goodName: `Goods Test ${stamp} bad-missing`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [999999999],
countryId: Number(countryId),
categoryId: Number(categoryId),
}),
).rejects.toThrow(BadRequestException);
});
it('replaces merged origin goods on update', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} replace`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const updated = await service.update(BigInt(created.id), {
mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])],
});
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
[originGoodIds[3].toString(), originGoodIds[4].toString()].sort(),
);
});
it('moves old primary into merged list when switching primary', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} switch`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const updated = await service.update(BigInt(created.id), {
originGoodId: Number(originGoodIds[2]),
mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])],
});
expect(updated.originGoodId).toBe(originGoodIds[2].toString());
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
[originGoodIds[1].toString(), originGoodIds[3].toString()].sort(),
);
});
it('cascades merged rows on good removal', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} cascade`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
await service.remove(BigInt(created.id));
const rows = await prisma.goodOriginGood.count({
where: { goodId: BigInt(created.id) },
});
expect(rows).toBe(0);
});
it('returns merged variants with source annotation in detail', async () => {
const v1 = await prisma.originGoodVariant.create({
data: {
originGoodId: originGoodIds[1],
sdsVariantId: `mv-pri-${stamp}`,
sku: `MV-PRI-${stamp}`,
colorName: '黑色',
},
});
const v2 = await prisma.originGoodVariant.create({
data: {
originGoodId: originGoodIds[2],
sdsVariantId: `mv-sec-${stamp}`,
sku: `MV-SEC-${stamp}`,
colorName: '白色',
},
});
try {
const created = await service.create({
goodName: `Goods Test ${stamp} variants`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const detail = await service.findOne(BigInt(created.id));
const sources = new Set(
detail.variants.map((v) => v['originGoodId'] as string),
);
expect(sources.has(originGoodIds[1].toString())).toBe(true);
expect(sources.has(originGoodIds[2].toString())).toBe(true);
expect(detail.variants).toHaveLength(2);
expect(detail.mergedOriginGoods.find((m) => m.id === originGoodIds[2].toString())?.variantCount).toBe(1);
} finally {
await prisma.originGoodVariant.delete({ where: { id: v1.id } });
await prisma.originGoodVariant.delete({ where: { id: v2.id } });
}
});
});
it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
+323 -5
View File
@@ -12,6 +12,13 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
import { SyncService } from '../sync/sync.service';
import { randomUUID } from 'crypto';
import {
CreateCustomGoodDto,
CustomGoodDetailDto,
CustomGoodVariantDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
const GOOD_INCLUDE = {
country: true,
@@ -26,6 +33,18 @@ const GOOD_INCLUDE = {
},
},
goodTags: { include: { tag: true } },
mergedOriginGoods: {
orderBy: { createdAt: 'asc' },
include: {
originGood: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
_count: { select: { variants: true } },
},
},
},
},
} satisfies Prisma.GoodInclude;
@Injectable()
@@ -68,6 +87,7 @@ export class GoodsService {
position: g.position,
originGood: g.originGood,
goodTags: g.goodTags,
mergedOriginGoods: g.mergedOriginGoods,
})),
total,
page,
@@ -88,11 +108,17 @@ export class GoodsService {
position: good.position,
originGood: good.originGood,
goodTags: good.goodTags,
mergedOriginGoods: good.mergedOriginGoods,
});
}
async create(dto: CreateGoodDto): Promise<GoodDto> {
await this.ensureReferences(dto);
const mergedIds = this.dedupeMergedIds(
BigInt(dto.originGoodId),
dto.mergedOriginGoodIds,
);
await this.ensureMergedOriginGoods(mergedIds);
const result = await this.prisma.$transaction(async (tx) => {
const created = await tx.good.create({
data: {
@@ -113,6 +139,14 @@ export class GoodsService {
})),
});
}
if (mergedIds.length > 0) {
await tx.goodOriginGood.createMany({
data: mergedIds.map((originGoodId) => ({
goodId: created.id,
originGoodId,
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: created.id },
include: GOOD_INCLUDE,
@@ -124,16 +158,133 @@ export class GoodsService {
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
mergedOriginGoods: result.mergedOriginGoods,
});
});
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
if (
result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId &&
!result.originGood.hasDetail
) {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
}
return result;
}
async createCustom(dto: CreateCustomGoodDto): Promise<GoodDetailDto> {
await this.ensureCountry(dto.countryId);
await this.ensureCategory(dto.categoryId);
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
const goodId = await this.prisma.$transaction(async (tx) => {
const originGood = await tx.originGood.create({
data: {
source: 'CUSTOM',
sdsGoodId: `custom-${randomUUID()}`,
goodName: dto.goodName,
goodImage: dto.goodImage ?? null,
goodPrice: this.decimal(dto.goodPrice),
detail: {
create: this.customDetailData(
dto.detail ?? {},
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput,
},
},
});
if (dto.variants?.length) {
await this.replaceCustomVariants(tx, originGood.id, dto.variants);
}
const good = await tx.good.create({
data: {
originGoodId: originGood.id,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId:
dto.positionId === undefined ? null : BigInt(dto.positionId),
goodName: dto.goodName,
goodImage: dto.goodImage ?? null,
goodPriority: dto.goodPriority ?? 0,
},
});
if (dto.tagIds?.length) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: good.id,
tagId: BigInt(tagId),
})),
});
}
return good.id;
});
return this.findOne(goodId);
}
async updateCustomContent(
id: bigint,
dto: UpdateCustomGoodContentDto,
): Promise<GoodDetailDto> {
const existing = await this.prisma.good.findUnique({
where: { id },
include: { originGood: true },
});
if (!existing) throw new NotFoundException(`Good ${id} not found`);
if (existing.originGood.source !== 'CUSTOM') {
throw new BadRequestException('SDS 映射商品的上游信息不可修改');
}
await this.prisma.$transaction(async (tx) => {
await tx.originGood.update({
where: { id: existing.originGoodId },
data: {
goodName: dto.goodName,
goodImage: dto.goodImage,
goodPrice:
dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice),
},
});
if (dto.detail !== undefined) {
await tx.originGoodDetail.upsert({
where: { originGoodId: existing.originGoodId },
create: {
originGoodId: existing.originGoodId,
...(this.customDetailData(
dto.detail,
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput),
},
update: this.customDetailData(dto.detail, true),
});
}
if (dto.variants !== undefined) {
await this.replaceCustomVariants(
tx,
existing.originGoodId,
dto.variants,
);
}
const goodData: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) goodData.goodName = dto.goodName;
if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage;
if (Object.keys(goodData).length) {
await tx.good.update({ where: { id }, data: goodData });
}
});
return this.findOne(id);
}
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
await this.findOne(id);
let mergedIds: bigint[] | undefined;
if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) {
const current = await this.prisma.good.findUniqueOrThrow({
where: { id },
select: { originGoodId: true },
});
const primaryId =
dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId;
mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds);
await this.ensureMergedOriginGoods(mergedIds);
}
const data: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) data.goodName = dto.goodName;
if (dto.originGoodId !== undefined) {
@@ -174,6 +325,17 @@ export class GoodsService {
});
}
}
if (mergedIds !== undefined) {
await tx.goodOriginGood.deleteMany({ where: { goodId: id } });
if (mergedIds.length > 0) {
await tx.goodOriginGood.createMany({
data: mergedIds.map((originGoodId) => ({
goodId: id,
originGoodId,
})),
});
}
}
const updated = await tx.good.update({
where: { id },
data,
@@ -186,17 +348,36 @@ export class GoodsService {
position: updated.position,
originGood: updated.originGood,
goodTags: updated.goodTags,
mergedOriginGoods: updated.mergedOriginGoods,
});
});
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
if (
result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId &&
!result.originGood.hasDetail
) {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
}
return result;
}
async remove(id: bigint): Promise<{ id: string }> {
await this.findOne(id);
await this.prisma.good.delete({ where: { id } });
const good = await this.prisma.good.findUnique({
where: { id },
include: { originGood: true },
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
await this.prisma.$transaction(async (tx) => {
await tx.good.delete({ where: { id } });
if (good.originGood.source === 'CUSTOM') {
const remaining = await tx.good.count({
where: { originGoodId: good.originGoodId },
});
if (remaining === 0) {
await tx.originGood.delete({ where: { id: good.originGoodId } });
}
}
});
return { id: id.toString() };
}
@@ -258,6 +439,24 @@ export class GoodsService {
})),
});
}
const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds);
if (itemMerged.length > 0) {
const existRows = await tx.originGood.findMany({
where: { id: { in: itemMerged } },
select: { id: true },
});
if (existRows.length !== itemMerged.length) {
const found = new Set(existRows.map((r) => r.id.toString()));
const missing = itemMerged.find((mid) => !found.has(mid.toString()));
throw new BadRequestException(`Origin good ${missing} not found`);
}
await tx.goodOriginGood.createMany({
data: itemMerged.map((originGoodId) => ({
goodId: row.id,
originGoodId,
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: row.id },
include: GOOD_INCLUDE,
@@ -269,13 +468,18 @@ export class GoodsService {
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
mergedOriginGoods: result.mergedOriginGoods,
}));
}
return created;
});
for (const goodId of new Set(
result
.filter((item) => !item.originGood?.hasDetail)
.filter(
(item) =>
item.originGood?.source === 'SDS' &&
!item.originGood.hasDetail,
)
.map((item) => item.originGood?.sdsGoodId)
.filter((id): id is string => Boolean(id)),
)) {
@@ -312,6 +516,31 @@ export class GoodsService {
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
}
/** Dedupe merged ids and reject any that equals the primary source. */
private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] {
if (!ids || ids.length === 0) return [];
const unique = [...new Set(ids.map((id) => BigInt(id)))];
if (unique.includes(primaryId)) {
throw new BadRequestException(
'mergedOriginGoodIds 不能包含主源 originGoodId',
);
}
return unique;
}
private async ensureMergedOriginGoods(ids: bigint[]) {
if (ids.length === 0) return;
const rows = await this.prisma.originGood.findMany({
where: { id: { in: ids } },
select: { id: true },
});
if (rows.length !== ids.length) {
const found = new Set(rows.map((r) => r.id.toString()));
const missing = ids.find((id) => !found.has(id.toString()));
throw new BadRequestException(`Origin good ${missing} not found`);
}
}
private async ensureCountry(id: number) {
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new BadRequestException(`Country ${id} not found`);
@@ -341,4 +570,93 @@ export class GoodsService {
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
}
}
private async ensurePosition(id: number) {
const position = await this.prisma.position.findUnique({
where: { id: BigInt(id) },
});
if (!position) throw new BadRequestException(`Position ${id} not found`);
}
private decimal(value: string | null | undefined): Prisma.Decimal | null {
return value === undefined || value === null || value === ''
? null
: new Prisma.Decimal(value);
}
private customDetailData(
detail: CustomGoodDetailDto,
preserveMissing = false,
): Prisma.OriginGoodDetailUncheckedUpdateInput {
const nullable = <T>(value: T | null | undefined): T | null | undefined =>
preserveMissing && value === undefined ? undefined : value ?? null;
const decimal = (value: string | null | undefined) =>
preserveMissing && value === undefined ? undefined : this.decimal(value);
const json = (
value: Record<string, unknown> | null | undefined,
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined =>
preserveMissing && value === undefined
? undefined
: value === null || value === undefined
? Prisma.DbNull
: (value as Prisma.InputJsonValue);
return {
productCode: nullable(detail.productCode),
englishName: nullable(detail.englishName),
blankDesignUrl: nullable(detail.blankDesignUrl),
detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl),
textureName: nullable(detail.textureName),
productionCycleHours: nullable(detail.productionCycleHours),
minWeightG: decimal(detail.minWeightG),
reminder: nullable(detail.reminder),
productionProcess: nullable(detail.productionProcess),
materialDescription: nullable(detail.materialDescription),
productPerformance: nullable(detail.productPerformance),
applicableScenarios: nullable(detail.applicableScenarios),
washingInstructions: nullable(detail.washingInstructions),
specialDescription: nullable(detail.specialDescription),
designExplanation: nullable(detail.designExplanation),
designArea: nullable(detail.designArea),
pictureRequest: nullable(detail.pictureRequest),
sizeChart: json(detail.sizeChart),
packageSpecs: json(detail.packageSpecs),
options: json(detail.options),
media: json(detail.media),
};
}
private async replaceCustomVariants(
tx: Prisma.TransactionClient,
originGoodId: bigint,
variants: CustomGoodVariantDto[],
): Promise<void> {
await tx.originGoodVariant.deleteMany({ where: { originGoodId } });
for (const variant of variants) {
await tx.originGoodVariant.create({
data: {
originGoodId,
sdsVariantId: `custom-${randomUUID()}`,
sku: variant.sku,
sizeId: variant.sizeId ?? null,
sizeName: variant.sizeName ?? null,
colorId: variant.colorId ?? null,
colorName: variant.colorName ?? null,
colorHex: variant.colorHex ?? null,
imageUrl: variant.imageUrl ?? null,
price: this.decimal(variant.price),
originalPrice: this.decimal(variant.originalPrice),
weightG: this.decimal(variant.weightG),
boxLengthCm: this.decimal(variant.boxLengthCm),
boxWidthCm: this.decimal(variant.boxWidthCm),
boxHeightCm: this.decimal(variant.boxHeightCm),
enabled: variant.enabled ?? true,
sortOrder: variant.sortOrder ?? 0,
designData:
variant.designData === null || variant.designData === undefined
? Prisma.DbNull
: (variant.designData as Prisma.InputJsonValue),
},
});
}
}
}
+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
@@ -77,4 +77,69 @@ describe('OriginGoodsService', () => {
expect(result.total).toBe(0);
expect(result.items.length).toBe(0);
});
describe('getTree merged references', () => {
/* eslint-disable @typescript-eslint/no-explicit-any */
function findOgNode(
treeResponse: { tree: any[] },
ogId: string,
): { configuredCount: number; configuredCountries: string[] } {
let found: { configuredCount: number; configuredCountries: string[] } | null = null;
const walk = (nodes: any[]) => {
for (const n of nodes) {
const hit = (n.originGoods ?? []).find((o: any) => o.id === ogId);
if (hit) {
found = hit;
return;
}
if (n.children?.length) walk(n.children);
}
};
walk(treeResponse.tree);
if (!found) throw new Error(`og node ${ogId} not found in tree`);
return found;
}
it('counts secondary references as configured', async () => {
const sdsCat = `tree-cat-${stamp}`;
await prisma.originGood.createMany({
data: [
{ sdsGoodId: `tree-a-${stamp}`, goodName: `Tree A ${stamp}`, sdsCategoryId: sdsCat },
{ sdsGoodId: `tree-b-${stamp}`, goodName: `Tree B ${stamp}`, sdsCategoryId: sdsCat },
],
});
createdSds.push(`tree-a-${stamp}`, `tree-b-${stamp}`);
const originA = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-a-${stamp}` } });
const originB = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-b-${stamp}` } });
const cat = await prisma.category.create({
data: { categoryName: `Tree Cat ${stamp}`, sdsCategoryId: sdsCat },
});
const country = await prisma.country.create({
data: { countryName: `Tree Country ${stamp}` },
});
const good = await prisma.good.create({
data: {
goodName: `Tree Good ${stamp}`,
originGoodId: originA.id,
countryId: country.id,
categoryId: cat.id,
},
});
await prisma.goodOriginGood.create({
data: { goodId: good.id, originGoodId: originB.id },
});
try {
const tree = await service.getTree();
const nodeB = findOgNode(tree, originB.id.toString());
expect(nodeB.configuredCount).toBeGreaterThanOrEqual(1);
expect(nodeB.configuredCountries).toContain(`Tree Country ${stamp}`);
} finally {
await prisma.good.delete({ where: { id: good.id } }).catch(() => undefined);
await prisma.country.delete({ where: { id: country.id } }).catch(() => undefined);
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
}
});
});
});
@@ -67,9 +67,12 @@ export class OriginGoodsService {
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
const { page, pageSize, keyword } = query;
const where: Prisma.OriginGoodWhereInput = keyword
? { goodName: { contains: keyword, mode: 'insensitive' } }
: {};
const where: Prisma.OriginGoodWhereInput = {
source: 'SDS',
...(keyword
? { goodName: { contains: keyword, mode: 'insensitive' as const } }
: {}),
};
const [total, rows] = await this.prisma.$transaction([
this.prisma.originGood.count({ where }),
@@ -111,7 +114,7 @@ export class OriginGoodsService {
* under a synthetic "未分类" root node.
*/
async getTree(): Promise<OriginGoodsTreeResponse> {
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] =
await Promise.all([
this.prisma.category.findMany({
where: { sdsCategoryId: { not: null } },
@@ -124,7 +127,7 @@ export class OriginGoodsService {
},
}),
this.prisma.originGood.findMany({
where: { delisted: false },
where: { delisted: false, source: 'SDS' },
orderBy: { goodName: 'asc' },
include: { detail: true, _count: { select: { variants: true } } },
}),
@@ -141,7 +144,12 @@ export class OriginGoodsService {
}),
this.prisma.goodTag.findMany({
select: {
good: { select: { originGoodId: true } },
good: {
select: {
originGoodId: true,
mergedOriginGoods: { select: { originGoodId: true } },
},
},
tag: {
select: {
tagName: true,
@@ -154,26 +162,57 @@ export class OriginGoodsService {
},
},
}),
// Secondary-source references (good_origin_goods)
this.prisma.goodOriginGood.groupBy({
by: ['originGoodId'],
_count: { _all: true },
}),
this.prisma.goodOriginGood.findMany({
select: {
originGoodId: true,
good: { select: { country: { select: { countryName: true } } } },
},
}),
]);
const countMap = new Map<string, number>();
configCounts.forEach((c) =>
countMap.set(c.originGoodId.toString(), c._count._all),
);
const countryMap = new Map<string, string[]>();
goodsWithCountries.forEach((g) => {
const key = g.originGoodId.toString();
const name = g.country?.countryName;
if (!name) return;
const arr = countryMap.get(key);
if (arr) arr.push(name);
else countryMap.set(key, [name]);
// Secondary (merged) references count towards configured status too.
mergedCounts.forEach((c) => {
const key = c.originGoodId.toString();
countMap.set(key, (countMap.get(key) ?? 0) + c._count._all);
});
const countryMap = new Map<string, string[]>();
const addCountry = (key: string, name?: string | null) => {
if (!name) return;
const arr = countryMap.get(key);
if (!arr?.includes(name)) {
countryMap.set(key, [...(arr ?? []), name]);
}
};
goodsWithCountries.forEach((g) =>
addCountry(g.originGoodId.toString(), g.country?.countryName),
);
mergedWithCountries.forEach((m) =>
addCountry(m.originGoodId.toString(), m.good.country?.countryName),
);
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
const addTag = (
key: string,
tagInfo: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number },
) => {
const arr = tagMap.get(key);
if (arr) {
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
} else {
tagMap.set(key, [tagInfo]);
}
};
goodsWithTags.forEach((gt) => {
const key = gt.good.originGoodId.toString();
const tagInfo = {
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
@@ -182,11 +221,10 @@ export class OriginGoodsService {
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
sortOrder: gt.tag.sortOrder,
};
const arr = tagMap.get(key);
if (arr) {
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
} else {
tagMap.set(key, [tagInfo]);
addTag(gt.good.originGoodId.toString(), tagInfo);
// A good's tags also mark its secondary origin goods as configured.
for (const m of gt.good.mergedOriginGoods) {
addTag(m.originGoodId.toString(), tagInfo);
}
});
@@ -20,6 +20,14 @@ export class PublicGoodDetailDto extends PublicGoodDto {
@ApiProperty({ nullable: true, type: Object })
media!: Record<string, unknown> | null;
@ApiProperty({ type: Array, description: 'Variant images grouped by color' })
mediaByColor!: Array<{
colorId: string | null;
colorName: string | null;
colorHex: string | null;
images: string[];
}>;
@ApiProperty({ nullable: true, type: Object })
options!: Record<string, unknown> | null;
@@ -0,0 +1,35 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
PublicQueryGoodDto,
PublicTagFilterDto,
} from './public-query-good.dto';
describe('PublicQueryGoodDto', () => {
it('parses tags from a JSON query parameter into nested DTOs', async () => {
const dto = plainToInstance(PublicQueryGoodDto, {
tags: JSON.stringify([
{ tagGroupId: '1', tagIds: ['11', '12'] },
{ tagGroupId: '2', tagIds: ['25'] },
]),
});
expect(dto.tags).toHaveLength(2);
expect(dto.tags?.[0]).toBeInstanceOf(PublicTagFilterDto);
expect(dto.tags?.[0]).toEqual({
tagGroupId: '1',
tagIds: ['11', '12'],
});
await expect(validate(dto)).resolves.toHaveLength(0);
});
it('rejects malformed group and tag ids', async () => {
const dto = plainToInstance(PublicQueryGoodDto, {
tags: JSON.stringify([
{ tagGroupId: 'craft', tagIds: ['11', 'bad'] },
]),
});
expect(await validate(dto)).not.toHaveLength(0);
});
});
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
import { plainToInstance, Transform, Type } from 'class-transformer';
import {
IsArray,
IsIn,
@@ -7,7 +7,8 @@ import {
IsNumberString,
IsOptional,
IsString,
Matches,
ValidateNested,
ArrayNotEmpty,
Max,
Min,
} from 'class-validator';
@@ -21,6 +22,32 @@ const stringList = ({ value }: { value: unknown }): string[] | undefined => {
.filter(Boolean);
};
const tagFilters = ({ value }: { value: unknown }): unknown => {
if (value === undefined || value === null || value === '') return undefined;
const values = Array.isArray(value) ? value : [value];
try {
return values.flatMap((item) => {
if (typeof item !== 'string') return [item];
const parsed = JSON.parse(item) as unknown;
return Array.isArray(parsed) ? parsed : [parsed];
}).map((item) => plainToInstance(PublicTagFilterDto, item));
} catch {
return value;
}
};
export class PublicTagFilterDto {
@ApiProperty({ example: '1' })
@IsNumberString()
tagGroupId!: string;
@ApiProperty({ type: [String], example: ['11', '12', '13'] })
@IsArray()
@ArrayNotEmpty()
@IsNumberString({}, { each: true })
tagIds!: string[];
}
export class PublicQueryGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@@ -47,21 +74,12 @@ export class PublicQueryGoodDto {
@IsNumberString()
categoryId?: string;
@ApiProperty({
required: false,
type: [String],
description:
'标签筛选项,格式为 tagGroupId:tagId;支持重复参数或逗号分隔。同组 OR,跨组 AND',
example: ['1:11', '1:12', '2:25'],
})
@ApiHideProperty()
@IsOptional()
@Transform(stringList)
@Transform(tagFilters)
@IsArray()
@Matches(/^\d+:\d+$/, {
each: true,
message: 'tagIds 每个元素必须为 tagGroupId:tagId',
})
tagIds?: string[];
@ValidateNested({ each: true })
tags?: PublicTagFilterDto[];
@ApiProperty({ required: false })
@IsOptional()
+29 -1
View File
@@ -4,18 +4,28 @@ import {
Param,
Query,
} from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import {
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import { PublicService } from './public.service';
import {
PublicCountryQueryDto,
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto';
import { PublicTagDto } from './dto/public-tag.dto';
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
import { PublicGoodDto } from './dto/public-good.dto';
@ApiTags('public')
@ApiExtraModels(PublicTagFilterDto)
@Controller('public')
export class PublicController {
constructor(private readonly service: PublicService) {}
@@ -47,6 +57,24 @@ export class PublicController {
@Get('goods')
@ApiOperation({ summary: '分页获取商品' })
@ApiQuery({
name: 'tags',
required: false,
description:
'标签筛选分组。参数值为 JSON 数组;同组 tagIds 按 OR 匹配,不同标签组按 AND 匹配',
content: {
'application/json': {
schema: {
type: 'array',
items: { $ref: getSchemaPath(PublicTagFilterDto) },
},
example: [
{ tagGroupId: '1', tagIds: ['11', '12'] },
{ tagGroupId: '2', tagIds: ['25'] },
],
},
},
})
getGoods(@Query() query: PublicQueryGoodDto) {
return this.service.getGoods(query);
}
+177 -8
View File
@@ -247,9 +247,12 @@ describe('PublicService', () => {
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
tagIds: filterTagIds
.slice(0, 2)
.map((tagId) => `${filterGroupIds[0]}:${tagId}`),
tags: [
{
tagGroupId: filterGroupIds[0].toString(),
tagIds: filterTagIds.slice(0, 2).map(String),
},
],
});
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
@@ -260,10 +263,16 @@ describe('PublicService', () => {
pageSize: 50,
countryId: countryId.toString(),
keyword: `Pub `,
tagIds: filterTagIds.map(
(tagId, index) =>
`${index < 2 ? filterGroupIds[0] : filterGroupIds[1]}:${tagId}`,
),
tags: [
{
tagGroupId: filterGroupIds[0].toString(),
tagIds: filterTagIds.slice(0, 2).map(String),
},
{
tagGroupId: filterGroupIds[1].toString(),
tagIds: [filterTagIds[2].toString()],
},
],
});
expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`);
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
@@ -274,7 +283,12 @@ describe('PublicService', () => {
service.getGoods({
page: 1,
pageSize: 20,
tagIds: [`${filterGroupIds[1]}:${filterTagIds[0]}`],
tags: [
{
tagGroupId: filterGroupIds[1].toString(),
tagIds: [filterTagIds[0].toString()],
},
],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -292,6 +306,36 @@ describe('PublicService', () => {
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
});
it('returns custom goods through the same public product contract', async () => {
const customPublicId = `custom-public-${stamp}`;
const origin = await prisma.originGood.create({
data: {
source: 'CUSTOM',
sdsGoodId: customPublicId,
goodName: `Pub Custom ${stamp}`,
goodPrice: 42,
detail: { create: { productCode: `CUSTOM-${stamp}` } },
},
});
const good = await prisma.good.create({
data: {
originGoodId: origin.id,
countryId,
categoryId,
goodName: `Pub Custom ${stamp}`,
},
});
try {
const detail = await service.getGood(customPublicId);
expect(detail.goodId).toBe(customPublicId);
expect(detail.goodName).toBe(`Pub Custom ${stamp}`);
expect(detail.productCode).toBe(`CUSTOM-${stamp}`);
} finally {
await prisma.good.delete({ where: { id: good.id } });
await prisma.originGood.delete({ where: { id: origin.id } });
}
});
it('getGood returns detail and 404 for unknown id', async () => {
const first = await service.getGoods({
page: 1,
@@ -335,4 +379,129 @@ describe('PublicService', () => {
const sortOrders = groups.map((g) => g.sortOrder);
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
});
describe('merged secondary origin goods', () => {
it('resolves a good by secondary sdsGoodId with merged variants', async () => {
const secondary = await prisma.originGood.create({
data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` },
});
const secVariant = await prisma.originGoodVariant.create({
data: {
originGoodId: secondary.id,
sdsVariantId: `pub-var-sec-${stamp}`,
sku: `PUB-SEC-${stamp}`,
colorId: 'black',
colorName: '黑色',
imageUrl: 'http://img/black-sec',
},
});
// Attach as secondary source of the highest-priority fixture good.
await prisma.goodOriginGood.create({
data: { goodId: goodIds[0], originGoodId: secondary.id },
});
try {
const detail = await service.getGood(`pub-secondary-${stamp}`);
expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源
expect(detail.variants.length).toBeGreaterThanOrEqual(2);
const black = detail.mediaByColor.find((g) => g.colorName === '黑色');
expect(black).toBeTruthy();
expect(black!.images).toContain('http://img/black-sec');
} finally {
await prisma.goodOriginGood.deleteMany({ where: { originGoodId: secondary.id } });
await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined);
await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined);
}
});
it('dedupes variants by color+size (primary wins) and merges specs/options', async () => {
const stamp2 = `${stamp}-merge2`;
const primaryOg = await prisma.originGood.create({
data: { sdsGoodId: `pub-pri-${stamp2}`, goodName: `Pub Pri ${stamp2}` },
});
const secondaryOg = await prisma.originGood.create({
data: { sdsGoodId: `pub-sec-${stamp2}`, goodName: `Pub Sec ${stamp2}` },
});
// Primary: Black|S and Black|M, size chart S/M, package S, options S/M.
await prisma.originGoodVariant.createMany({
data: [
{ originGoodId: primaryOg.id, sdsVariantId: `v1-${stamp2}`, sku: `SK1-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 16 },
{ originGoodId: primaryOg.id, sdsVariantId: `v2-${stamp2}`, sku: `SK2-${stamp2}`, colorName: 'black', sizeName: 'M', price: 16 },
],
});
await prisma.originGoodDetail.create({
data: {
originGoodId: primaryOg.id,
sizeChart: { rows: [{ sizeName: 'S', measurements: [{ key: 'chest', cm: '94' }] }, { sizeName: 'M', measurements: [{ key: 'chest', cm: '100' }] }] },
packageSpecs: { rows: [{ sizeName: 'S' }] },
options: { sizes: [{ name: 'S' }, { name: 'M' }] },
media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i2', url: 'http://img/pri-b', sortOrder: 1 }], primaryImageUrl: 'http://img/pri-a' },
},
});
// Secondary: duplicate Black|S with a DIFFERENT price (must be dropped,
// primary wins), plus a unique color Kelly|S; specs add XXXL rows.
await prisma.originGoodVariant.createMany({
data: [
{ originGoodId: secondaryOg.id, sdsVariantId: `v3-${stamp2}`, sku: `SK3-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 20 },
{ originGoodId: secondaryOg.id, sdsVariantId: `v4-${stamp2}`, sku: `SK4-${stamp2}`, colorName: 'Kelly', sizeName: 'S', price: 22 },
],
});
await prisma.originGoodDetail.create({
data: {
originGoodId: secondaryOg.id,
sizeChart: { rows: [{ sizeName: 'XXXL', measurements: [{ key: 'chest', cm: '120' }] }] },
packageSpecs: { rows: [{ sizeName: 'M' }, { sizeName: 'XXXL' }] },
options: { sizes: [{ name: 'XXXL' }] },
media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i9', url: 'http://img/sec-x', sortOrder: 0 }], primaryImageUrl: 'http://img/pri-a' },
},
});
const mergedGood = await prisma.good.create({
data: {
goodName: `Pub Merged ${stamp2}`,
originGoodId: primaryOg.id,
countryId,
categoryId,
},
});
await prisma.goodOriginGood.create({
data: { goodId: mergedGood.id, originGoodId: secondaryOg.id },
});
try {
const detail = await service.getGood(`pub-pri-${stamp2}`);
// Variants: 3 unique color+size combos (case-insensitive); duplicate
// Black|S from the secondary deduped.
expect(
detail.variants.map((v) => `${v.colorName}/${v.sizeName}`.toLowerCase()).sort(),
).toEqual(['black/m', 'black/s', 'kelly/s']);
const blackS = detail.variants.find((v) => v.colorName === 'Black' && v.sizeName === 'S');
expect(blackS!.price).toBe('16'); // primary price wins over secondary 20
// Size chart: S/M from primary, XXXL appended from secondary.
const chartSizes = (detail.sizeChart as any).rows.map((r: any) => r.sizeName).sort();
expect(chartSizes).toEqual(['M', 'S', 'XXXL']);
// Package specs: S from primary, M/XXXL appended from secondary.
const pkgSizes = (detail.packageSpecs as any).rows.map((r: any) => r.sizeName).sort();
expect(pkgSizes).toEqual(['M', 'S', 'XXXL']);
// Options: sizes unioned S/M + XXXL.
const optSizes = (detail.options as any).sizes.map((s: any) => s.name).sort();
expect(optSizes).toEqual(['M', 'S', 'XXXL']);
// Media gallery: primary images first, secondary-only URL appended,
// duplicate URL (pri-a) kept once. Entries keep their object shape.
const media = detail.media as any;
expect(media.images.map((i: any) => i.url)).toEqual([
'http://img/pri-a',
'http://img/pri-b',
'http://img/sec-x',
]);
expect(media.primaryImageUrl).toBe('http://img/pri-a');
} finally {
await prisma.goodOriginGood.deleteMany({ where: { goodId: mergedGood.id } });
await prisma.good.delete({ where: { id: mergedGood.id } });
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } });
await prisma.originGoodDetail.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } });
await prisma.originGood.delete({ where: { id: primaryOg.id } });
await prisma.originGood.delete({ where: { id: secondaryOg.id } }).catch(() => undefined);
}
});
});
});
+236 -16
View File
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import {
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
PublicTagFilterDto,
} from './dto/public-query-good.dto';
import { PublicCategoryNodeDto } from './dto/public-category.dto';
import { PublicCountryDto } from './dto/public-country.dto';
@@ -33,6 +34,17 @@ const PUBLIC_GOOD_INCLUDE = {
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
},
},
mergedOriginGoods: {
orderBy: { createdAt: 'asc' as const },
include: {
originGood: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
},
},
},
},
goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude;
@@ -145,9 +157,7 @@ export class PublicService {
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
}
const tagFilters = await this.buildTagGroupFilters([
...new Set(query.tagIds ?? []),
]);
const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
if (tagFilters.length) where.AND = tagFilters;
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
@@ -199,14 +209,26 @@ export class PublicService {
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
const good = await this.prisma.good.findFirst({
where: { originGood: { sdsGoodId: goodId, delisted: false } },
where: {
OR: [
{ originGood: { sdsGoodId: goodId, delisted: false } },
// Merged secondary sources also resolve to the same good.
{
mergedOriginGoods: {
some: { originGood: { sdsGoodId: goodId, delisted: false } },
},
},
],
},
include: PUBLIC_GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
});
if (!good) {
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
}
return this.toPublicGoodDetail(good);
const dto = this.toPublicGoodDetail(good);
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
return dto;
}
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
@@ -271,9 +293,80 @@ export class PublicService {
};
}
/** Group distinct variant images by color so the frontend can switch media per color.
* Only color-specific photos are included (main / result / detail images);
* design-layer素材图 and the product-level blank garment photo are excluded
* because they are not per-color gallery photos. */
private groupImagesByColor(
variants: Array<PublicGoodRow['originGood']['variants'][number]>,
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
const groups = new Map<string, {
colorId: string | null;
colorName: string | null;
colorHex: string | null;
images: string[];
}>();
for (const variant of variants) {
const key = variant.colorId ?? `variant:${variant.sdsVariantId}`;
let group = groups.get(key);
if (!group) {
group = {
colorId: variant.colorId,
colorName: variant.colorName,
colorHex: variant.colorHex,
images: [],
};
groups.set(key, group);
}
const design = (variant.designData ?? {}) as {
detailImgUrls?: Array<{ imageUrl?: unknown }>;
prototypeResultGroups?: Array<{ resultImage?: unknown }>;
};
const urls: unknown[] = [
variant.imageUrl,
...(design.prototypeResultGroups ?? []).map((item) => item?.resultImage),
...(design.detailImgUrls ?? []).map((image) => image?.imageUrl),
];
for (const url of urls) {
const value = typeof url === 'string' ? url.trim() : '';
if (value && !group.images.includes(value)) {
group.images.push(value);
}
}
}
return [...groups.values()];
}
/** Leaf categories often have no icon upstream; fall back to the nearest ancestor that has one. */
private async resolveCategoryIcon(category: PublicGoodRow['category']): Promise<string | null> {
if (category.categoryIcon) return category.categoryIcon;
let cursor = category.parentCategoryId;
for (let depth = 0; cursor !== null && depth < 10; depth++) {
const parent = await this.prisma.category.findUnique({
where: { id: cursor },
select: { categoryIcon: true, parentCategoryId: true },
});
if (!parent) break;
if (parent.categoryIcon) return parent.categoryIcon;
cursor = parent.parentCategoryId;
}
return null;
}
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
const base = this.toPublicGood(good);
const detail = good.originGood.detail;
// Merge primary and secondary origin goods: variants deduped by
// color+size (primary wins), detail specs filled from secondaries
// for sizes/options the primary does not have.
const secondaryDetails = good.mergedOriginGoods
.map((m) => m.originGood.detail)
.filter((d): d is NonNullable<typeof d> => Boolean(d));
const secondaryVariantLists = good.mergedOriginGoods.map((m) => m.originGood.variants);
const allVariants = this.dedupeVariants([
...good.originGood.variants,
...secondaryVariantLists.flat(),
]);
return {
...base,
productCode: detail?.productCode ?? null,
@@ -292,11 +385,12 @@ export class PublicService {
designArea: detail?.designArea ?? null,
pictureRequest: detail?.pictureRequest ?? null,
},
media: (detail?.media as Record<string, unknown> | null) ?? null,
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) => ({
media: this.mergeMedia(detail, secondaryDetails),
mediaByColor: this.groupImagesByColor(allVariants),
options: this.mergeOptions(detail, secondaryDetails),
sizeChart: this.mergeRowsByKey(detail, secondaryDetails, 'sizeChart'),
packageSpecs: this.mergeRowsByKey(detail, secondaryDetails, 'packageSpecs'),
variants: allVariants.map((variant) => ({
id: variant.sdsVariantId,
sku: variant.sku,
sizeId: variant.sizeId,
@@ -318,17 +412,143 @@ export class PublicService {
};
}
/** Norm key for variant/dedupe matching: case-insensitive, trimmed. */
private static normName(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase();
}
/** Dedupe variants by color+size (case-insensitive); first (primary) wins. */
private dedupeVariants(variants: PublicGoodRow['originGood']['variants']) {
const seen = new Set<string>();
const result: PublicGoodRow['originGood']['variants'] = [];
for (const variant of variants) {
const key = `${PublicService.normName(variant.colorName)}|${PublicService.normName(variant.sizeName)}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(variant);
}
return result;
}
/**
* Merge a `{ rows: [...] }` spec (sizeChart / packageSpecs) across primary
* and secondary details: primary rows win, rows for sizes the primary
* lacks are appended from secondaries in order.
*/
private mergeRowsByKey(
primary: PublicGoodRow['originGood']['detail'],
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
field: 'sizeChart' | 'packageSpecs',
): Record<string, unknown> | null {
const primaryRows = (primary?.[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
if (!Array.isArray(primaryRows) && secondaries.length === 0) return null;
const rows: Array<Record<string, unknown>> = Array.isArray(primaryRows) ? [...primaryRows] : [];
const seen = new Set(rows.map((r) => PublicService.normName(String(r?.sizeName ?? ''))));
for (const sec of secondaries) {
const secRows = (sec[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
if (!Array.isArray(secRows)) continue;
for (const row of secRows) {
const key = PublicService.normName(String(row?.sizeName ?? ''));
if (seen.has(key)) continue;
seen.add(key);
rows.push(row);
}
}
if (rows.length === 0) return null;
return { ...(primary?.[field] as Record<string, unknown> | null ?? {}), rows };
}
/**
* Merge options across details: sizes/colors are unioned by name,
* primary entries win and secondary-only ones are appended.
*/
private mergeOptions(
primary: PublicGoodRow['originGood']['detail'],
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
): Record<string, unknown> | null {
const primaryOptions = primary?.options as
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
| null;
if (!primaryOptions && secondaries.length === 0) return null;
const merged: Record<string, unknown> = { ...(primaryOptions ?? {}) };
for (const listKey of ['sizes', 'colors'] as const) {
const base = Array.isArray(primaryOptions?.[listKey])
? [...(primaryOptions![listKey] as Array<Record<string, unknown>>)]
: null;
if (!base && secondaries.length === 0) continue;
const rows = base ?? [];
const seen = new Set(rows.map((r) => PublicService.normName(String(r?.name ?? ''))));
for (const sec of secondaries) {
const secOptions = sec.options as
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
| null;
const secRows = secOptions?.[listKey];
if (!Array.isArray(secRows)) continue;
for (const row of secRows) {
const key = PublicService.normName(String(row?.name ?? ''));
if (seen.has(key)) continue;
seen.add(key);
rows.push(row);
}
}
merged[listKey] = rows;
}
return merged;
}
/**
* Merge the gallery `media` across details: primary images first,
* secondary-only image URLs appended (URL-deduped). `primaryImageUrl`
* stays the primary's. Image entries keep their original shape
* (`{id,url,sortOrder}` objects or plain strings).
*/
private mergeMedia(
primary: PublicGoodRow['originGood']['detail'],
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
): Record<string, unknown> | null {
const imageUrl = (img: unknown): string | null => {
if (typeof img === 'string') return img;
if (img && typeof img === 'object' && typeof (img as { url?: unknown }).url === 'string') {
return (img as { url: string }).url;
}
return null;
};
const primaryMedia = (primary?.media as Record<string, unknown> | null) ?? null;
const primaryImages = Array.isArray(primaryMedia?.images)
? (primaryMedia!.images as unknown[])
: null;
if (!primaryImages && secondaries.length === 0) return null;
const images: unknown[] = primaryImages ? [...primaryImages] : [];
const seen = new Set(images.map(imageUrl).filter((u): u is string => Boolean(u)));
for (const sec of secondaries) {
const secMedia = sec.media as { images?: unknown } | null;
if (!Array.isArray(secMedia?.images)) continue;
for (const img of secMedia!.images as unknown[]) {
const url = imageUrl(img);
if (!url || seen.has(url)) continue;
seen.add(url);
images.push(img);
}
}
if (images.length === 0) return null;
return { ...(primaryMedia ?? {}), images };
}
private async buildTagGroupFilters(
selectedTagValues: string[],
selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> {
const selections = selectedTagValues.map((value) => {
const [tagGroupId, tagId] = value.split(':');
if (!tagGroupId || !tagId || !/^\d+$/.test(tagGroupId) || !/^\d+$/.test(tagId)) {
const selections = selectedGroups.flatMap((group) => {
if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
throw new BadRequestException(
'tagIds 每个元素必须 tagGroupId:tagId',
'tags 每个元素必须包含合法的 tagGroupIdtagIds',
);
}
return { tagGroupId, tagId };
return group.tagIds.map((tagId) => {
if (!/^\d+$/.test(tagId)) {
throw new BadRequestException('tagIds 必须全部为数字字符串');
}
return { tagGroupId: group.tagGroupId, tagId };
});
});
const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
const selected = uniqueTagIds.length
+1 -1
View File
@@ -38,7 +38,7 @@ export class SyncController {
}
@Post('product-details')
@ApiOperation({ summary: 'Manually sync details for all configured products (async)' })
@ApiOperation({ summary: 'Manually sync details for all active origin products (async)' })
async syncProductDetails() {
return this.service.startProductDetailSync();
}
+64
View File
@@ -276,3 +276,67 @@ describe('SyncService', () => {
});
});
});
describe('SyncService product detail scopes', () => {
const originGoods = [
{ id: 1n, sdsGoodId: 'all-1' },
{ id: 2n, sdsGoodId: 'all-2' },
];
function createService() {
const prisma = {
originGood: { findMany: jest.fn().mockResolvedValue(originGoods) },
} as unknown as PrismaService;
const sds = {
fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
} as unknown as SdsClientService;
const scopedService = new SyncService(prisma, sds);
jest
.spyOn(scopedService as any, 'persistProductDetail')
.mockResolvedValue(undefined);
return { scopedService, prisma, sds };
}
it('manual detail sync selects every active origin product', async () => {
const { scopedService, prisma, sds } = createService();
const result = await scopedService.syncAllProductDetails();
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { delisted: false, source: 'SDS' } }),
);
expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
});
it('hourly detail refresh remains limited to configured products', async () => {
const { scopedService, prisma } = createService();
await scopedService.syncConfiguredProductDetails();
expect(prisma.originGood.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { delisted: false, source: 'SDS', goods: { some: {} } },
}),
);
});
it('keeps hourly category/product sync separate from the daily detail sync', async () => {
const { scopedService } = createService();
const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
inserted: 0, updated: 0, total: 0, deletedStale: 0,
});
const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({
inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0,
});
const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({
total: 0, synced: 0, failed: 0,
});
await scopedService.hourlyCron();
expect(categories).toHaveBeenCalledTimes(1);
expect(products).toHaveBeenCalledTimes(1);
expect(details).not.toHaveBeenCalled();
await scopedService.dailyProductDetailCron();
expect(details).toHaveBeenCalledTimes(1);
});
});
+104 -22
View File
@@ -1,4 +1,9 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@@ -23,8 +28,6 @@ export interface ProductSyncResult {
total: number;
leafCategories: number;
delisted: number;
detailsSynced: number;
detailFailures: number;
}
/**
@@ -110,6 +113,16 @@ export class SyncService {
return { message: 'Product sync started' };
}
/** Refresh all active SDS product details once per day at 03:30. */
@Cron('0 30 3 * * *', { timeZone: 'Asia/Shanghai' })
async dailyProductDetailCron(): Promise<void> {
try {
await this.syncProductDetails();
} catch (err) {
this.logger.error('Daily product detail sync failed', err as Error);
}
}
async startProductDetailSync(): Promise<{ message: string }> {
if (this.running.details) {
return { message: 'Product detail sync already in progress' };
@@ -341,11 +354,19 @@ export class SyncService {
const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size);
if (runDelist) {
const delistedResult = await this.prisma.originGood.updateMany({
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false },
where: {
source: 'SDS',
sdsGoodId: { notIn: [...seenSdsGoodIds] },
delisted: false,
},
data: { delisted: true },
});
const reactivatedResult = await this.prisma.originGood.updateMany({
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true },
where: {
source: 'SDS',
sdsGoodId: { in: [...seenSdsGoodIds] },
delisted: true,
},
data: { delisted: false },
});
delistedCount = delistedResult.count;
@@ -357,17 +378,12 @@ export class SyncService {
);
}
// Only hydrate full details for products selected in the website catalog.
// This keeps the hourly sync bounded and preserves the existing Good/tag
// merchandising model. A failed detail request never erases cached data.
const detailResult = await this.syncConfiguredProductDetails();
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length} detailsSynced=${detailResult.synced} detailFailures=${detailResult.failed}`,
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
},
});
return {
@@ -376,8 +392,6 @@ export class SyncService {
total,
leafCategories: leafRows.length,
delisted: delistedCount,
detailsSynced: detailResult.synced,
detailFailures: detailResult.failed,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -402,7 +416,11 @@ export class SyncService {
});
}
async syncProductDetails(): Promise<{ synced: number; failed: number }> {
async syncProductDetails(): Promise<{
total: number;
synced: number;
failed: number;
}> {
if (this.running.details) {
throw new Error('Product detail sync already in progress');
}
@@ -411,13 +429,20 @@ export class SyncService {
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
});
try {
const result = await this.syncConfiguredProductDetails();
const result = await this.syncAllProductDetails(async (progress) => {
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
message: `processed=${progress.processed}/${progress.total} synced=${progress.synced} failed=${progress.failed}`,
},
});
});
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `synced=${result.synced} failed=${result.failed}`,
message: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
},
});
return result;
@@ -440,11 +465,14 @@ export class SyncService {
}> {
const originGood = await this.prisma.originGood.findUnique({
where: { sdsGoodId: goodId },
select: { id: true },
select: { id: true, source: true },
});
if (!originGood) {
throw new NotFoundException(`SDS product ${goodId} not found locally`);
}
if (originGood.source !== 'SDS') {
throw new BadRequestException('自定义商品不支持从 SDS 同步详情');
}
const upstream = await this.sds.fetchProductDetail(goodId);
const normalized = normalizeProductDetail(upstream);
await this.persistProductDetail(originGood.id, upstream);
@@ -463,25 +491,72 @@ export class SyncService {
}
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
const configured = await this.prisma.originGood.findMany({
where: { delisted: false, goods: { some: {} } },
const result = await this.syncMatchingProductDetails({
delisted: false,
source: 'SDS',
goods: { some: {} },
});
return { synced: result.synced, failed: result.failed };
}
async syncAllProductDetails(
onProgress?: (progress: {
processed: number;
total: number;
synced: number;
failed: number;
}) => Promise<void>,
): Promise<{ total: number; synced: number; failed: number }> {
return this.syncMatchingProductDetails(
{ delisted: false, source: 'SDS' },
onProgress,
2,
);
}
private async syncMatchingProductDetails(
where: Prisma.OriginGoodWhereInput,
onProgress?: (progress: {
processed: number;
total: number;
synced: number;
failed: number;
}) => Promise<void>,
attempts = 1,
): Promise<{ total: number; synced: number; failed: number }> {
const originGoods = await this.prisma.originGood.findMany({
where,
select: { id: true, sdsGoodId: true },
orderBy: { id: 'asc' },
});
let synced = 0;
let failed = 0;
for (const originGood of configured) {
let processed = 0;
for (const originGood of originGoods) {
let lastError: unknown;
let succeeded = false;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
await this.persistProductDetail(originGood.id, upstream);
synced++;
succeeded = true;
break;
} catch (error) {
lastError = error;
}
}
if (!succeeded) {
failed++;
const message = error instanceof Error ? error.message : String(error);
const message = lastError instanceof Error ? lastError.message : String(lastError);
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
}
processed++;
if (onProgress && (processed % 10 === 0 || processed === originGoods.length)) {
await onProgress({ processed, total: originGoods.length, synced, failed });
}
return { synced, failed };
}
return { total: originGoods.length, synced, failed };
}
async importProductDetail(upstream: SdsProductDetail): Promise<{
@@ -533,6 +608,13 @@ export class SyncService {
const { variants, ...detail } = normalized;
await this.prisma.$transaction(async (tx) => {
const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull;
// Backfill the origin good's price from upstream min_price when present
if (upstream.min_price !== undefined && upstream.min_price !== null) {
await tx.originGood.update({
where: { id: originGoodId },
data: { goodPrice: new Prisma.Decimal(Number(upstream.min_price)) },
});
}
await tx.originGoodDetail.upsert({
where: { originGoodId },
create: {
+15 -3
View File
@@ -1,33 +1,45 @@
import {
Controller,
Post,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { randomUUID } from 'crypto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
const UPLOAD_DIR = join(process.cwd(), 'uploads');
// Explicit safe-image whitelist. SVG is deliberately excluded: it can
// carry scripts and is served from the same origin (stored XSS).
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i;
const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i;
@UseGuards(JwtAuthGuard)
@Controller('upload')
export class UploadController {
@Post('image')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, cb) => {
const ext = extname(file.originalname) || '.png';
const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname))
? extname(file.originalname).toLowerCase()
: '.png';
cb(null, `${randomUUID()}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new BadRequestException('仅支持图片文件'), false);
if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
}
cb(null, true);
},
View File
View File
@@ -316,7 +316,15 @@ export function useProductCenter() {
if (query.countryId) params.countryId = query.countryId;
if (query.categoryId) params.categoryId = query.categoryId;
if (!options.ignoreTags && query.tagIds.length > 0) {
params.tagIds = query.tagIds.join(',');
const grouped = new Map<string, string[]>();
for (const tagId of query.tagIds) {
const groupId = tags.value.find((tag) => tag.id === tagId)?.group?.id;
if (!groupId) continue;
grouped.set(groupId, [...(grouped.get(groupId) ?? []), tagId]);
}
params.tags = JSON.stringify(
[...grouped].map(([tagGroupId, tagIds]) => ({ tagGroupId, tagIds })),
);
}
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
+2 -1
View File
@@ -1,6 +1,7 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
devtools: { enabled: false },
sourcemap: { server: false, client: false },
modules: ['@nuxtjs/seo'],
+8318
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# 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
COPY deploy/h5 /usr/share/nginx/html/h5
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:
+2
View File
@@ -0,0 +1,2 @@
<!DOCTYPE html><html lang=zh-CN><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><title>印美达POD供应链</title><script>var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
document.write('<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' + (coverSupport ? ', viewport-fit=cover' : '') + '" />')</script><link rel=stylesheet href=/h5/static/index.149e085d.css></head><body><noscript><strong>Please enable JavaScript to continue.</strong></noscript><div id=app></div><script src=/h5/static/js/chunk-vendors.9aacc1b6.js></script><script src=/h5/static/js/index.1e01d76d.js></script></body></html>
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.1 3.19995H7.8L2 20.8H4.8L6.3 15.8H12.4L13.9 20.8H16.9L11.1 3.19995ZM7 13.6L7.7 11.2C8.3 9.29995 8.8 7.29995 9.3 5.29995H9.4C10 7.29995 10.5 9.29995 11.1 11.2L11.8 13.6H7ZM22 3.19995H19.2V20.8H22V3.19995Z" fill="#FF6800"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.75 20.7501L11.223 21.5684C11.5066 21.726 11.6484 21.8047 11.7986 21.8356C11.9315 21.863 12.0685 21.863 12.2015 21.8356C12.3516 21.8047 12.4934 21.726 12.777 21.5684L14.25 20.7501M5.25 18.2501L3.82297 17.4573C3.52346 17.2909 3.37368 17.2077 3.26463 17.0893C3.16816 16.9847 3.09515 16.8606 3.05048 16.7254C3 16.5726 3 16.4013 3 16.0586V14.5001M3 9.50009V7.94153C3 7.59889 3 7.42757 3.05048 7.27477C3.09515 7.13959 3.16816 7.01551 3.26463 6.91082C3.37368 6.79248 3.52345 6.70928 3.82297 6.54288L5.25 5.75009M9.75 3.25008L11.223 2.43177C11.5066 2.27421 11.6484 2.19543 11.7986 2.16454C11.9315 2.13721 12.0685 2.13721 12.2015 2.16454C12.3516 2.19543 12.4934 2.27421 12.777 2.43177L14.25 3.25008M18.75 5.75008L20.177 6.54288C20.4766 6.70928 20.6263 6.79248 20.7354 6.91082C20.8318 7.01551 20.9049 7.13959 20.9495 7.27477C21 7.42757 21 7.59889 21 7.94153V9.50008M21 14.5001V16.0586C21 16.4013 21 16.5726 20.9495 16.7254C20.9049 16.8606 20.8318 16.9847 20.7354 17.0893C20.6263 17.2077 20.4766 17.2909 20.177 17.4573L18.75 18.2501M9.75 10.7501L12 12.0001M12 12.0001L14.25 10.7501M12 12.0001V14.5001M3 7.00008L5.25 8.25008M18.75 8.25008L21 7.00008M12 19.5001V22.0001" stroke="#FF6800" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 2.4578C14.053 2.16035 13.0452 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 10.2847 21.5681 8.67022 20.8071 7.25945M17 5.75H17.005M10.5001 21.8883L10.5002 19.6849C10.5002 19.5656 10.5429 19.4502 10.6205 19.3596L13.1063 16.4594C13.3106 16.2211 13.2473 15.8556 12.9748 15.6999L10.1185 14.0677C10.0409 14.0234 9.97663 13.9591 9.93234 13.8814L8.07046 10.6186C7.97356 10.4488 7.78657 10.3511 7.59183 10.3684L2.06418 10.8607M21 6C21 8.20914 19 10 17 12C15 10 13 8.20914 13 6C13 3.79086 14.7909 2 17 2C19.2091 2 21 3.79086 21 6ZM17.25 5.75C17.25 5.88807 17.1381 6 17 6C16.8619 6 16.75 5.88807 16.75 5.75C16.75 5.61193 16.8619 5.5 17 5.5C17.1381 5.5 17.25 5.61193 17.25 5.75Z" stroke="#FF6800" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 2V22H23V2H1ZM12.0524 3.5C12.2469 3.63134 12.3919 3.81891 12.4662 4.035C12.5764 4.31994 12.6469 4.6175 12.6757 4.92C12.7149 5.20699 12.6883 5.49867 12.5979 5.77491C12.5075 6.05115 12.3553 6.30537 12.1519 6.52C11.753 6.91856 11.2095 7.15647 10.6329 7.185H7.67333L9.96762 8.57C10.3781 8.82008 10.6952 9.18758 10.8738 9.62C11.066 10.0242 11.094 10.4822 10.9524 10.905C10.7732 11.4672 10.405 11.9575 9.90476 12.3C9.655 12.4886 9.38025 12.645 9.08762 12.765C8.81252 12.8861 8.51318 12.9488 8.21024 12.9488C7.9073 12.9488 7.60796 12.8861 7.33286 12.765C6.8121 12.5325 6.40772 12.1141 6.20667 11.6C6.17524 11.53 6.14905 11.46 6.12286 11.39L5.26905 9L4.58286 11.45C4.45612 11.8863 4.19298 12.2752 3.82857 12.565C3.49145 12.8531 3.05535 13.0132 2.60286 13.015H2.57143V3.5H12.0524ZM2.57143 14.5H2.61857C3.44255 14.4939 4.23663 14.2046 4.85524 13.685C5.0435 13.5319 5.21866 13.3647 5.37905 13.185C5.8881 13.7725 6.58643 14.1827 7.36523 14.3519C8.14403 14.521 8.95956 14.4395 9.68476 14.12C10.0927 13.9597 10.478 13.7514 10.8319 13.5C11.5989 12.9643 12.1647 12.2068 12.4452 11.34C12.693 10.5887 12.6483 9.77736 12.3195 9.055C12.2336 8.83283 12.1267 8.6186 12 8.415C12.5087 8.21816 12.9663 7.91786 13.341 7.535C13.6909 7.16659 13.9546 6.73122 14.1145 6.2579C14.2744 5.78457 14.3267 5.28416 14.2681 4.79C14.2259 4.35831 14.1274 3.93334 13.9748 3.525V3.5H21.4286V12.535C20.5882 12.5887 19.783 12.8774 19.1133 13.365C18.4318 13.8365 17.9518 14.5273 17.7619 15.31C17.7004 15.5459 17.6636 15.7871 17.6519 16.03C17.1243 15.9104 16.5759 15.9001 16.0438 16C15.5261 16.1029 15.0375 16.3105 14.611 16.6089C14.1845 16.9074 13.83 17.2897 13.5714 17.73C13.345 18.1093 13.1691 18.5141 13.0476 18.935C12.8815 19.4411 12.8036 19.9698 12.8171 20.5H2.57143V14.5ZM14.3886 20.5C14.3623 20.1136 14.4084 19.7259 14.5248 19.355C14.6127 19.0553 14.7413 18.7679 14.9071 18.5C15.056 18.2384 15.2622 18.0105 15.5117 17.8319C15.7612 17.6533 16.0481 17.5281 16.3529 17.465C16.9403 17.3631 17.5463 17.4813 18.0448 17.795C18.0996 17.8247 18.1521 17.8581 18.2019 17.895L20.2971 19.31L19.391 17C19.2291 16.5677 19.209 16.0985 19.3333 15.655C19.4369 15.2079 19.7091 14.8128 20.0981 14.545C20.4925 14.264 20.9598 14.0911 21.4495 14.045V20.5H14.3886Z" fill="#FF6800"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6H10M7 9L10 6L7 3" stroke="#FF6800" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.898 16.875H12.8215C11.0215 16.7805 8.59034 14.9985 6.31559 12.1117L5.47634 11.052C3.20159 8.16525 2.01021 5.34487 2.29034 3.51113C2.48609 2.21738 4.31871 1.125 5.36609 1.125C5.88134 1.125 6.06921 1.39388 6.13671 1.55588C6.72509 2.664 7.45859 4.58663 7.46421 5.27625V5.32463L7.44734 5.37075C7.31571 5.733 7.02434 5.90625 6.76559 6.06038C6.42471 6.26288 6.23234 6.3945 6.20759 6.77587C6.20196 6.89062 6.29646 7.41825 7.79608 9.35775L8.43734 10.1711C9.94258 12.0364 10.4151 12.2366 10.5242 12.2524C10.8876 12.3075 11.0608 12.1444 11.3207 11.8418C11.5187 11.6089 11.7437 11.3512 12.1138 11.295L12.1633 11.286L12.2083 11.3017C12.8653 11.4424 14.5371 12.5764 15.4933 13.4201C15.6283 13.5146 15.868 13.8004 15.679 14.427C15.382 15.4114 14.0848 16.875 12.898 16.875Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 890 B

@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.24985 4.5V2.25C5.24985 1.83579 5.58564 1.5 5.99985 1.5H14.9998C15.4141 1.5 15.7498 1.83579 15.7498 2.25V12.75C15.7498 13.1642 15.4141 13.5 14.9998 13.5H12.7498V15.7493C12.7498 16.1639 12.4124 16.5 11.9948 16.5H3.00499C2.58794 16.5 2.25 16.1665 2.25 15.7493L2.25195 5.25065C2.25202 4.83608 2.58948 4.5 3.00706 4.5H5.24985ZM3.75181 6L3.75014 15H11.2498V6H3.75181ZM6.74985 4.5H12.7498V12H14.2498V3H6.74985V4.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="72" height="12" viewBox="0 0 72 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.05848 5.78271V9.09609C8.25115 8.8399 9.34012 8.57005 10.3219 8.28654L10.6365 9.36936C9.14998 9.77585 7.5632 10.1414 5.88309 10.4659L5.40256 9.48209C5.70678 9.31471 5.85543 9.0756 5.85543 8.75793V1.20887C7.45949 0.959511 8.89762 0.631587 10.1663 0.225099L10.9096 1.1713C9.81719 1.51971 8.53117 1.83739 7.05156 2.11749V4.64523H10.5293V5.7793H7.05848V5.78271ZM13.6891 10.0901L13.3606 8.9458L14.4842 8.96971C14.8126 8.96971 14.9751 8.79209 14.9751 8.43342V1.98085H12.4929V11.7707H11.283V0.860453H16.185V8.68278C16.185 9.62214 15.7494 10.0901 14.8748 10.0901H13.6891Z" fill="#FF6800"/>
<path d="M24.9731 6.93727C24.9316 7.18663 24.8798 7.4189 24.821 7.6341H29.5502V8.7545H25.3914C26.2488 9.72461 27.8494 10.3873 30.1932 10.7357L29.564 11.8561C26.8676 11.3335 25.0976 10.3805 24.2575 9.00386C23.9982 9.45134 23.6767 9.84417 23.2999 10.1755C22.4668 10.8997 21.0079 11.4701 18.9268 11.8937L18.4359 10.7357C20.2439 10.4214 21.5368 9.96714 22.3181 9.37961C22.5601 9.18149 22.771 8.97312 22.9473 8.75792H19.027V7.63752H23.5661C23.6422 7.39841 23.7078 7.12172 23.7666 6.81429L24.9731 6.93727ZM19.148 1.48213H21.8583C21.6889 1.09956 21.5092 0.75797 21.3156 0.46079L22.5774 0.262673C22.7295 0.576932 22.8954 0.986834 23.0821 1.48213H25.5159C25.7095 1.0654 25.8547 0.648666 25.9584 0.225099L27.2444 0.385643C27.1338 0.757972 26.9851 1.12347 26.7915 1.48213H29.4154V2.55472H24.8901V3.50091H28.7482V4.52225H24.8901V5.48211H29.8302V6.5786H18.7366V5.48211H23.6767V4.52225H19.8567V3.50091H23.6767V2.55472H19.1515V1.48213H19.148Z" fill="#FF6800"/>
<path d="M41.0382 11.4974C40.6026 11.4974 39.8179 11.4906 38.6943 11.4735C37.8439 11.4667 37.1491 11.395 36.6028 11.2618C36.0636 11.1115 35.6038 10.8211 35.2166 10.3907C35.0403 10.1823 34.8709 10.0799 34.7119 10.0799C34.4422 10.0799 33.979 10.6571 33.3256 11.8117L32.444 11.0021C33.0836 9.97397 33.6575 9.32496 34.1726 9.05852V5.55726H32.4579V4.41295H35.3203V9.14733C35.386 9.19857 35.4517 9.26005 35.5104 9.3352C35.8388 9.66654 36.1569 9.91248 36.468 10.0696C36.8552 10.2438 37.4429 10.3429 38.2346 10.3702C39.2267 10.3873 40.1083 10.3941 40.8826 10.3941L42.6354 10.3805C43.2161 10.3565 43.6725 10.3292 44.0078 10.3053L43.7174 11.5009H41.0382V11.4974ZM33.4604 0.337824C34.3178 0.986838 35.0437 1.62902 35.6418 2.26779L34.7603 3.13883C34.2555 2.51714 33.5503 1.84422 32.6411 1.12006L33.4604 0.337824ZM36.2848 2.88947H38.9709C39.0366 2.2917 39.0746 1.68026 39.085 1.05857V0.225099H40.2811V0.798966C40.2811 1.52996 40.2431 2.2268 40.167 2.89289H43.1919V4.0372H40.3814C40.8031 5.57434 41.8644 7.12173 43.5722 8.68278L42.7391 9.55382C41.2353 8.06792 40.2258 6.60593 39.7142 5.16785C39.1334 7.11831 38.1136 8.5803 36.6512 9.55382L35.9184 8.58371C37.3876 7.6546 38.3486 6.13796 38.7911 4.0372H36.2848V2.88947Z" fill="#FF6800"/>
<path d="M52.599 0.228514C54.1789 1.8408 55.9938 3.10467 58.0438 4.02695L57.4527 5.08587C56.8062 4.77844 56.2185 4.45735 55.6861 4.12601V5.24642H52.7753V7.11489H56.3188V8.25921H52.7753V10.2267H57.4665V11.3847H46.888V10.2267H51.5654V8.25921H48.0115V7.11489H51.5654V5.24642H48.6546V4.12601C48.1153 4.45052 47.5103 4.77844 46.8396 5.10978L46.2588 4.07477C48.454 3.03635 50.2793 1.75541 51.7417 0.225099H52.599V0.228514ZM55.6619 4.11235C54.3517 3.31645 53.1867 2.37709 52.1703 1.29768C51.23 2.31902 50.065 3.25838 48.6787 4.11235H55.6619Z" fill="#FF6800"/>
<path d="M60.6473 4.69988H61.9852V2.05942H60.509V0.94926H64.5814V2.05942H63.1952V4.69988H64.3187V5.82028H63.1952V8.01326C63.6895 7.79806 64.1527 7.58628 64.5814 7.37791V8.52222C63.3784 9.11317 62.0509 9.62555 60.5989 10.0662L60.2947 8.89456C60.9654 8.74426 61.5289 8.60079 61.9852 8.45733V5.81686H60.6473V4.69988ZM64.692 2.25754H67.4646V0.225099H68.6123V2.25412H70.327C69.9813 1.86471 69.5353 1.44456 68.9891 0.997083L69.6702 0.348069C70.327 0.836537 70.8594 1.31134 71.2708 1.76907L70.7902 2.25412H71.4955V3.38819H68.6089V4.49834C68.7782 5.02097 68.9718 5.51285 69.1896 5.96716C69.684 5.3933 70.1438 4.76478 70.5621 4.07477L71.4575 4.73404C70.8767 5.63241 70.2924 6.37365 69.7047 6.9646C70.327 8.02693 71.091 8.95604 72.0002 9.75535L71.2051 10.6879C70.1127 9.62555 69.2484 8.37876 68.6089 6.95093V10.4009C68.6089 11.19 68.1975 11.5828 67.3747 11.5828H66.0541L65.8017 10.4624C66.2719 10.5205 66.6626 10.5513 66.9737 10.5513C67.3021 10.5513 67.4646 10.4044 67.4646 10.114V7.54871C66.6902 8.62129 65.7948 9.54698 64.7785 10.3258L64.1493 9.30446C65.3834 8.49832 66.4897 7.38474 67.4646 5.96716V3.38819H64.692V2.25754ZM65.577 4.17383C66.1475 5.00389 66.5865 5.71439 66.8873 6.30533L66.0541 6.8792C65.7015 6.23019 65.2348 5.5231 64.654 4.7477L65.577 4.17383Z" fill="#FF6800"/>
<line x1="0.253237" y1="1.10693e-08" x2="0.253237" y2="11.1424" stroke="#FF6800" stroke-width="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg width="73" height="30" viewBox="0 0 73 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M47.9351 20.0809C41.0877 26.1054 33.2907 28.4959 30.5222 25.4211C29.4344 24.2135 29.2848 22.3443 29.916 20.1529C27.9686 23.6941 27.5504 26.8884 29.1447 28.659C31.9151 31.7338 39.7121 29.3414 46.5575 23.3187C50.717 19.66 53.5679 15.5027 54.5445 12.1132C53.1075 14.7255 50.8417 17.5254 47.9351 20.0809Z" fill="#FF6800"/>
<path d="M2.29076 9.25257H0.604346C0.270517 9.25257 0 9.51987 0 9.84972V11.5161C0 11.8459 0.270517 12.1132 0.604346 12.1132H2.29076C2.62459 12.1132 2.8951 11.8459 2.8951 11.5161V9.84972C2.8951 9.51987 2.62459 9.25257 2.29076 9.25257Z" fill="#FF6800"/>
<path d="M2.29076 13.0497H0.604346C0.270517 13.0497 0 13.3189 0 13.6487V22.2685C0 22.5984 0.270517 22.8657 0.604346 22.8657H2.29076C2.62459 22.8657 2.8951 22.5984 2.8951 22.2685V13.6487C2.8951 13.3189 2.62459 13.0497 2.29076 13.0497Z" fill="#FF6800"/>
<path d="M8.71549 13.0573C8.18022 13.0648 7.66796 13.1672 7.19408 13.3454C7.08856 13.1691 6.89478 13.0497 6.67223 13.0497H4.85728C4.52345 13.0497 4.25293 13.317 4.25293 13.6468V17.6013V17.6999V22.2989C4.25293 22.6287 4.52345 22.896 4.85728 22.896H6.67223C7.00606 22.896 7.27658 22.6287 7.27658 22.2989V17.7017V17.5349C7.27658 16.6951 7.98452 16.0146 8.84212 16.0468C9.65751 16.0771 10.2925 16.7653 10.2925 17.5728V22.3235C10.2925 22.6533 10.5631 22.9206 10.8969 22.9206H12.7118C13.0457 22.9206 13.3162 22.6533 13.3162 22.3235V17.5349C13.3181 15.0421 11.248 13.0193 8.71549 13.0573Z" fill="#FF6800"/>
<path d="M67.7277 13.0573C67.2289 13.0648 66.7512 13.1539 66.3041 13.3113V7.84594C66.3041 7.51609 66.0336 7.24879 65.6998 7.24879H63.8848C63.551 7.24879 63.2805 7.51609 63.2805 7.84594V16.9074C63.2805 16.9738 63.2862 17.0382 63.2939 17.1008C63.2747 17.2657 63.2651 17.4326 63.2651 17.6013V22.2988C63.2651 22.6287 63.5357 22.896 63.8695 22.896H65.6844C66.0183 22.896 66.2888 22.6287 66.2888 22.2988V17.5349C66.2888 16.6951 66.9967 16.0146 67.8543 16.0468C68.6697 16.0771 69.3048 16.7653 69.3048 17.5728V22.3235C69.3048 22.6533 69.5753 22.9206 69.9091 22.9206H71.7241C72.0579 22.9206 72.3284 22.6533 72.3284 22.3235V17.5349C72.3284 15.0421 70.2583 13.0193 67.7277 13.0573Z" fill="#FF6800"/>
<path d="M23.1448 13.1312H21.3299C20.996 13.1312 20.7255 13.3985 20.7255 13.7283V14.3255C20.7255 15.1122 20.1058 15.7586 19.325 15.8155C19.2655 15.8136 19.2041 15.8136 19.1446 15.8136C19.1293 15.8136 19.1158 15.8155 19.1005 15.8155C18.7072 15.7871 18.2851 15.6089 17.8861 15.2278C17.769 15.116 17.7019 14.9605 17.7019 14.7994V7.84594C17.7019 7.51609 17.4314 7.24879 17.0975 7.24879H15.2826C14.9487 7.24879 14.6782 7.51609 14.6782 7.84594V13.7283V16.2269V20.3614V22.3842C14.6782 22.714 14.9487 22.9813 15.2826 22.9813H17.0975C17.4314 22.9813 17.7019 22.714 17.7019 22.3842V22.3387V20.2932C17.7019 19.5084 18.3196 18.8619 19.0986 18.8051C19.1715 18.8069 19.2444 18.8069 19.3173 18.8051C20.1135 18.86 20.7255 19.5368 20.7255 20.3311V22.3861C20.7255 22.7159 20.996 22.9832 21.3299 22.9832H23.1448C23.4786 22.9832 23.7492 22.7159 23.7492 22.3861V20.2951C23.7492 19.1501 23.3117 18.1055 22.5942 17.3112C23.3117 16.5188 23.7492 15.4724 23.7492 14.3255V13.7283C23.7492 13.3985 23.4786 13.1312 23.1448 13.1312Z" fill="#FF6800"/>
<path d="M29.701 16.0714C29.7605 16.0733 29.818 16.0468 29.8526 15.9994C30.3495 15.2904 30.8963 14.5833 31.4872 13.8819C31.6157 13.7283 31.5562 13.4971 31.3701 13.4212C30.8176 13.1956 30.2094 13.0743 29.5725 13.0838C29.066 13.0914 28.5825 13.1843 28.1316 13.3435C28.0837 13.1843 27.9378 13.0686 27.7613 13.0686H25.7296C25.3958 13.0686 25.1252 13.3359 25.1252 13.6658V17.2847C25.1176 17.3984 25.1118 17.5122 25.1118 17.6278V22.3254C25.1118 22.4941 25.1828 22.6477 25.2979 22.7557C25.4073 22.8676 25.5608 22.9377 25.7315 22.9377H27.5465C27.8803 22.9377 28.1508 22.6704 28.1508 22.3405V17.3662C28.2487 16.6174 28.9106 16.043 29.701 16.0714Z" fill="#FF6800"/>
<path d="M30.9828 18.8069C31.1152 19.6145 31.4624 20.3861 31.9766 21.0249H31.9747C33.8146 23.3415 37.4541 23.5386 39.8177 21.5481C39.9444 21.442 39.9578 21.2524 39.8504 21.1273L38.4536 19.5046C38.35 19.3832 38.1678 19.3662 38.0392 19.461C37.0032 20.2249 35.9346 20.4031 34.8487 19.8553L38.2752 18.4581L40.7578 17.4439C40.9132 17.3814 40.9861 17.2051 40.9209 17.0534C40.66 16.4354 40.3281 15.5918 39.9731 15.1349C39.0484 13.8478 37.5404 13.0572 35.902 13.0591H35.9039C32.8553 13.0364 30.4571 15.8439 30.9828 18.8069ZM33.7973 17.6562C33.9201 16.0449 35.9864 15.1823 37.22 16.261C36.3413 16.6193 34.6741 17.2998 33.7973 17.6581V17.6562Z" fill="#FF6800"/>
<path d="M57.9139 15.952C58.6065 15.6695 59.3835 15.7473 59.9955 16.1397C60.2315 16.2913 60.5461 16.2534 60.7457 16.0563L61.8469 14.9681C62.1079 14.7103 62.0752 14.2857 61.7798 14.0658C60.9203 13.4231 59.8651 13.0705 58.7734 13.0724C56.5153 13.0724 54.5372 14.5719 53.954 16.7273C53.3688 18.8828 54.3223 21.1576 56.2793 22.2742C58.0309 23.2732 60.197 23.1197 61.7779 21.9425C62.0733 21.7225 62.1079 21.2979 61.8469 21.0382L60.7457 19.9501C60.5442 19.751 60.2296 19.7188 59.9898 19.8704C59.3777 20.2591 58.6046 20.3368 57.9139 20.0543C57.0736 19.7112 56.5268 18.9017 56.5268 18.0032C56.5268 17.1065 57.0736 16.2951 57.9139 15.952Z" fill="#FF6800"/>
<path d="M51.7179 14.479V13.3814C51.7179 13.2165 51.5817 13.0819 51.4147 13.0819H49.147C48.9801 13.0819 48.8439 13.2165 48.8439 13.3814V13.5236C48.1609 13.207 47.3954 13.0383 46.5877 13.0592C43.9535 13.1312 41.8047 15.2563 41.7395 17.861C41.6915 19.715 42.6815 21.3434 44.176 22.223C44.9435 21.6467 45.7013 21.0268 46.4457 20.3671C46.5531 20.2704 46.6606 20.1738 46.768 20.0771C46.7546 20.0771 46.7431 20.079 46.7296 20.079C45.4826 20.079 44.483 19.0079 44.6269 17.7472C44.7343 16.788 45.5171 16.0146 46.4879 15.9084C47.7637 15.7681 48.8477 16.7539 48.8477 17.9861C48.8477 18.0013 48.8477 18.0164 48.8458 18.0335C49.9336 16.8695 50.9006 15.6733 51.7179 14.479Z" fill="#FF6800"/>
<path d="M48.8467 22.3766V22.8467H51.4175C51.5845 22.8467 51.7207 22.7121 51.7207 22.5472V19.2657C50.7307 20.3842 50.0208 21.3358 48.8467 22.3766Z" fill="#FF6800"/>
<path d="M36.5144 9.03833C43.1162 2.69528 51.0705 0.200528 54.2822 3.46494C55.6827 4.88862 55.9494 7.17106 55.2606 9.81937C56.878 6.24785 57.018 3.06874 55.2971 1.319C52.2236 -1.80324 44.3805 0.807156 37.7807 7.14831C35.6165 9.2279 33.8534 11.4516 32.5718 13.6089C33.6615 12.0582 34.9853 10.5094 36.5144 9.03833Z" fill="#FF6800"/>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.58317 23.8334V11.9167M2.1665 14.0834V21.6667C2.1665 22.8634 3.13655 23.8334 4.33317 23.8334H18.8782C20.4824 23.8334 21.8466 22.6631 22.0905 21.0776L23.2572 13.4943C23.5601 11.5256 22.0368 9.75008 20.045 9.75008H16.2498C15.6515 9.75008 15.1665 9.26505 15.1665 8.66675V4.83807C15.1665 3.36274 13.9705 2.16675 12.4952 2.16675C12.1432 2.16675 11.8244 2.37399 11.6815 2.69554L7.86911 11.2734C7.69523 11.6647 7.30727 11.9167 6.87914 11.9167H4.33317C3.13655 11.9167 2.1665 12.8868 2.1665 14.0834Z" stroke="#FF6800" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 689 B

@@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33215 8.10647L7.97753 11.4246C8.21925 11.5972 8.34012 11.6835 8.46661 11.7034C8.5779 11.7209 8.69189 11.7032 8.79269 11.653C8.90725 11.5958 8.99637 11.4769 9.1746 11.2393L10.1562 9.93048C10.2066 9.86327 10.2318 9.82967 10.2614 9.80068C10.2877 9.77494 10.3166 9.75196 10.3475 9.73208C10.3824 9.70967 10.4208 9.69262 10.4976 9.6585L14.6885 7.79585C14.8623 7.71864 14.9491 7.68003 15.0146 7.61973C15.0726 7.56641 15.1182 7.50108 15.1483 7.42832C15.1823 7.34607 15.1886 7.25122 15.2013 7.06153L15.5082 2.4586M14.6248 14.6251L17.4588 15.8396C17.7876 15.9805 17.9521 16.0511 18.0392 16.1681C18.1156 16.2708 18.1531 16.3972 18.1452 16.5249C18.1362 16.6705 18.037 16.8194 17.8385 17.117L16.5071 19.1142C16.4131 19.2552 16.366 19.3258 16.3039 19.3769C16.2489 19.4221 16.1855 19.456 16.1173 19.4767C16.0404 19.5001 15.9556 19.5001 15.7861 19.5001H13.6245C13.3999 19.5001 13.2876 19.5001 13.192 19.4625C13.1076 19.4293 13.0329 19.3755 12.9747 19.3059C12.9088 19.2272 12.8733 19.1206 12.8023 18.9075L12.03 16.5907C11.9881 16.4649 11.9672 16.4021 11.9617 16.3383C11.9568 16.2816 11.9608 16.2245 11.9737 16.1692C11.9882 16.1068 12.0178 16.0475 12.0771 15.929L12.6637 14.7557C12.7828 14.5173 12.8424 14.3981 12.9343 14.3241C13.0151 14.2589 13.1132 14.2184 13.2164 14.2072C13.3336 14.1947 13.46 14.2368 13.7129 14.3211L14.6248 14.6251ZM23.8332 13.0001C23.8332 18.9831 18.9829 23.8334 12.9998 23.8334C7.01675 23.8334 2.1665 18.9831 2.1665 13.0001C2.1665 7.01699 7.01675 2.16675 12.9998 2.16675C18.9829 2.16675 23.8332 7.01699 23.8332 13.0001Z" stroke="#FF6800" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.437 2.16675H9.20178C9.00734 2.16675 8.91013 2.16675 8.8243 2.19636C8.7484 2.22253 8.67927 2.26526 8.62191 2.32144C8.55705 2.38496 8.51357 2.47192 8.42661 2.64583L3.87661 11.7458C3.66896 12.1611 3.56513 12.3688 3.59007 12.5376C3.61185 12.6849 3.69338 12.8169 3.81547 12.9023C3.9553 13.0001 4.18745 13.0001 4.65178 13.0001H11.3745L8.12449 23.8334L21.3337 10.135C21.7793 9.67284 22.0022 9.44176 22.0152 9.24403C22.0264 9.07241 21.9556 8.90561 21.8242 8.79461C21.6728 8.66675 21.3519 8.66675 20.7098 8.66675H12.9995L15.437 2.16675Z" stroke="#FF6800" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 728 B

@@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.2083 7.88436L13 13.0001M13 13.0001L3.79163 7.88436M13 13.0001V23.2918M22.75 17.3969V8.60337C22.75 8.23217 22.75 8.04658 22.6953 7.88104C22.647 7.7346 22.5678 7.60018 22.4633 7.48676C22.3452 7.35856 22.183 7.26843 21.8584 7.08816L13.8418 2.63446C13.5345 2.46377 13.3809 2.37842 13.2183 2.34496C13.0742 2.31535 12.9258 2.31535 12.7818 2.34496C12.6191 2.37842 12.4655 2.46377 12.1582 2.63446L4.14155 7.08816C3.81707 7.26843 3.65483 7.35856 3.53668 7.48676C3.43217 7.60018 3.35308 7.7346 3.30469 7.88104C3.25 8.04658 3.25 8.23217 3.25 8.60337V17.3969C3.25 17.7681 3.25 17.9537 3.30469 18.1192C3.35308 18.2657 3.43217 18.4001 3.53668 18.5134C3.65483 18.6417 3.81707 18.7318 4.14155 18.9121L12.1582 23.3658C12.4655 23.5365 12.6191 23.6218 12.7818 23.6553C12.9258 23.685 13.0742 23.685 13.2183 23.6553C13.3809 23.6218 13.5345 23.5365 13.8418 23.3658L21.8584 18.9121C22.183 18.7318 22.3452 18.6417 22.4633 18.5134C22.5678 18.4001 22.647 18.2657 22.6953 18.1192C22.75 17.9537 22.75 17.7681 22.75 17.3969Z" stroke="#FF6800" stroke-width="2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Some files were not shown because too many files have changed in this diff Show More