fix(storage): 打通对象存储边界与生产构建
This commit is contained in:
parent
2f02d72565
commit
1d4e8cffc2
@ -4,7 +4,11 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ApprovalWorkflowStatus, BusinessAccountType } from '@prisma/client'
|
||||
import { AlertCircle, Clock3, FileStack, Lock, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
import { AuthProvider, useAuth } from '@/hooks/useAuth'
|
||||
import { BUSINESS_ACCOUNT_TYPE_LABELS, BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types'
|
||||
import {
|
||||
BUSINESS_ACCOUNT_TYPE_LABELS,
|
||||
BUSINESS_ACCOUNT_TYPE_OPTIONS,
|
||||
type AppBusinessAccountType,
|
||||
} from '@/lib/business-account-types'
|
||||
|
||||
type FieldMatrixItem = {
|
||||
key: string
|
||||
@ -113,7 +117,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
|
||||
}, [accessKey])
|
||||
|
||||
const groupedFields = useMemo(() => {
|
||||
const groups = new Map<BusinessAccountType, FieldMatrixItem[]>()
|
||||
const groups = new Map<AppBusinessAccountType, FieldMatrixItem[]>()
|
||||
for (const role of APPROVAL_BUSINESS_ROLE_ORDER) {
|
||||
groups.set(role, [])
|
||||
}
|
||||
@ -132,7 +136,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
|
||||
}, [workflow])
|
||||
|
||||
const groupedFieldSections = useMemo(() => {
|
||||
const sections = new Map<BusinessAccountType, Array<{ title: string; fields: FieldMatrixItem[] }>>()
|
||||
const sections = new Map<AppBusinessAccountType, Array<{ title: string; fields: FieldMatrixItem[] }>>()
|
||||
for (const role of APPROVAL_BUSINESS_ROLE_ORDER) {
|
||||
const roleFields = groupedFields.get(role) || []
|
||||
const bySection = new Map<string, FieldMatrixItem[]>()
|
||||
@ -283,7 +287,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
|
||||
{APPROVAL_BUSINESS_ROLE_ORDER.map((role) => {
|
||||
const sections = groupedFieldSections.get(role) || []
|
||||
if (sections.length === 0) return null
|
||||
const mine = myBusinessRoles.includes(role)
|
||||
const mine = myBusinessRoles.some((myRole) => myRole === role)
|
||||
return (
|
||||
<section
|
||||
key={role}
|
||||
|
||||
@ -37,7 +37,10 @@ import {
|
||||
ObsidianRailGroup,
|
||||
} from '@/components/platform/ObsidianControlShell'
|
||||
import { canAssignRoleByHierarchy, getAssignableRolesForRole } from '@/lib/role-scope'
|
||||
import { BUSINESS_ACCOUNT_TYPE_LABELS, BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types'
|
||||
import {
|
||||
BUSINESS_ACCOUNT_TYPE_LABELS,
|
||||
DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS,
|
||||
} from '@/lib/business-account-types'
|
||||
import { useDebounce } from '@/hooks/usePerformance'
|
||||
import { getApiErrorMessage } from '@/lib/utils'
|
||||
|
||||
@ -367,7 +370,7 @@ function UsersPageContent() {
|
||||
label="全部类型"
|
||||
onClick={() => setBusinessRoleFilter('')}
|
||||
/>
|
||||
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
|
||||
{DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
|
||||
<ObsidianRailButton
|
||||
key={option.value}
|
||||
active={businessRoleFilter === option.value}
|
||||
|
||||
@ -8,7 +8,7 @@ import Button from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
|
||||
import { getApiErrorMessage } from '@/lib/utils';
|
||||
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
import { DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference';
|
||||
|
||||
interface ActivationUser {
|
||||
@ -259,7 +259,7 @@ export function ActivateUserModal({
|
||||
业务类型
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
|
||||
{DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 rounded-md border border-[#dde3e5] bg-white px-3 py-2 text-xs font-bold text-[#405054]">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@ -12,7 +12,7 @@ import { Select } from '@/components/ui/Select';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AlertCircle, Check } from 'lucide-react';
|
||||
import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
|
||||
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
import { DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference';
|
||||
import { getApiErrorMessage } from '@/lib/utils';
|
||||
|
||||
@ -220,7 +220,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[12px] font-semibold leading-5 tracking-normal text-[var(--obs-shell-muted)]">业务类型</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
|
||||
{DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
|
||||
<label key={option.value} className={businessOptionClass}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@ -13,7 +13,7 @@ import { Select } from '@/components/ui/Select';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
|
||||
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
import { DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
|
||||
import { getApiErrorMessage } from '@/lib/utils';
|
||||
|
||||
interface UserData {
|
||||
@ -310,7 +310,7 @@ export function EditUserModal({
|
||||
业务类型
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
|
||||
{DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 rounded-md border border-[#dde3e5] bg-white px-3 py-2 text-xs font-bold text-[#405054]">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@ -26,11 +26,27 @@ export const BUSINESS_ACCOUNT_TYPE_LABELS: Record<AppBusinessAccountType, string
|
||||
EXTERNAL_INTAKE: '外部录入员',
|
||||
}
|
||||
|
||||
export const DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{
|
||||
value: BusinessAccountType
|
||||
label: string
|
||||
}> = BUSINESS_ACCOUNT_TYPE_OPTIONS.filter(
|
||||
(option): option is { value: BusinessAccountType; label: string } =>
|
||||
option.value !== EXTERNAL_INTAKE_BUSINESS_ROLE,
|
||||
)
|
||||
|
||||
const DATABASE_BUSINESS_ACCOUNT_ROLE_SET = new Set<BusinessAccountType>(
|
||||
DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value),
|
||||
)
|
||||
|
||||
export function isDatabaseBusinessAccountType(value: unknown): value is BusinessAccountType {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
DATABASE_BUSINESS_ACCOUNT_ROLE_SET.has(value as BusinessAccountType)
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeBusinessRoles(value: unknown): BusinessAccountType[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const allowed = new Set(BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value))
|
||||
return value.filter((item): item is BusinessAccountType => {
|
||||
return typeof item === 'string' && allowed.has(item as AppBusinessAccountType)
|
||||
})
|
||||
return value.filter(isDatabaseBusinessAccountType)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
jest.mock('@/lib/object-storage-url', () => ({
|
||||
resolveObjectStorageUrlForClientAccess: jest.fn((value: string) => {
|
||||
if (value === 'https://cdn.example.com/prod/materials/textures/oak.webp') {
|
||||
return '/api/storage/object?key=prod%2Fmaterials%2Ftextures%2Foak.webp'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
|
||||
import { resolveObjectStorageUrlForClientAccess } from '@/lib/object-storage-url'
|
||||
|
||||
const POLYHAVEN_HOSTS = new Set(['dl.polyhaven.org', 'polyhaven.com', 'cdn.polyhaven.com'])
|
||||
const DEFAULT_MATERIAL_THUMBNAIL_PATHS = new Set([
|
||||
|
||||
244
furniture-3d-system/src/lib/object-storage-url.ts
Normal file
244
furniture-3d-system/src/lib/object-storage-url.ts
Normal file
@ -0,0 +1,244 @@
|
||||
export type StorageProvider = 's3' | 'qiniu'
|
||||
|
||||
const PRIVATE_OBJECT_ROUTE_PATH = '/api/storage/object'
|
||||
|
||||
function trimToNull(value: string | undefined | null): string | null {
|
||||
const normalized = value?.trim()
|
||||
return normalized ? normalized : null
|
||||
}
|
||||
|
||||
function readBooleanEnv(value: string | undefined, fallback: boolean): boolean {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (!normalized) return fallback
|
||||
return ['1', 'true', 'yes', 'on'].includes(normalized)
|
||||
}
|
||||
|
||||
function normalizePrefix(value: string | undefined): string {
|
||||
const normalized = trimToNull(value)
|
||||
if (!normalized) return ''
|
||||
return normalized.replace(/^\/+|\/+$/g, '') + '/'
|
||||
}
|
||||
|
||||
function normalizeProvider(value: string | null): StorageProvider {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (!normalized || normalized === 's3' || normalized === 'minio') {
|
||||
return 's3'
|
||||
}
|
||||
if (normalized === 'qiniu' || normalized === 'kodo') {
|
||||
return 'qiniu'
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[Storage] 不支持的 STORAGE_PROVIDER:${value}。可选值:s3、minio、qiniu。`,
|
||||
)
|
||||
}
|
||||
|
||||
const STORAGE_PROVIDER = normalizeProvider(
|
||||
trimToNull(process.env.STORAGE_PROVIDER) ||
|
||||
trimToNull(process.env.OBJECT_STORAGE_PROVIDER),
|
||||
)
|
||||
|
||||
const FILE_PATH_PREFIX = normalizePrefix(
|
||||
trimToNull(process.env.OBJECT_STORAGE_FILE_PATH) ||
|
||||
trimToNull(process.env.STORAGE_FILE_PATH) ||
|
||||
(STORAGE_PROVIDER === 'qiniu'
|
||||
? trimToNull(process.env.QINIU_FILE_PATH)
|
||||
: trimToNull(process.env.S3_FILE_PATH)) ||
|
||||
undefined,
|
||||
)
|
||||
|
||||
function normalizeObjectKey(key: string): string {
|
||||
const normalizedKey = key.trim().replace(/^\/+/, '')
|
||||
if (!normalizedKey) {
|
||||
throw new Error('[Storage] 对象 key 不能为空。')
|
||||
}
|
||||
if (
|
||||
normalizedKey.includes('..') ||
|
||||
normalizedKey.includes('\\') ||
|
||||
/^(https?:|data:|file:|\/\/)/i.test(normalizedKey) ||
|
||||
/[\u0000-\u001f\u007f]/.test(normalizedKey)
|
||||
) {
|
||||
throw new Error('[Storage] 对象 key 包含非法字符或路径。')
|
||||
}
|
||||
|
||||
if (!FILE_PATH_PREFIX) {
|
||||
return normalizedKey
|
||||
}
|
||||
|
||||
return normalizedKey.startsWith(FILE_PATH_PREFIX)
|
||||
? normalizedKey
|
||||
: `${FILE_PATH_PREFIX}${normalizedKey}`
|
||||
}
|
||||
|
||||
function buildPrivateObjectAccessUrl(key: string): string {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const params = new URLSearchParams({ key: objectKey })
|
||||
return `${PRIVATE_OBJECT_ROUTE_PATH}?${params.toString()}`
|
||||
}
|
||||
|
||||
function readPrivateObjectAccessKey(value: string): string | null {
|
||||
const normalized = trimToNull(value)
|
||||
if (!normalized) return null
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'http://storage.local')
|
||||
if (parsed.pathname !== PRIVATE_OBJECT_ROUTE_PATH) {
|
||||
return null
|
||||
}
|
||||
|
||||
return trimToNull(parsed.searchParams.get('key') ?? undefined)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildS3PublicBaseUrl(): string | null {
|
||||
const configured =
|
||||
trimToNull(process.env.S3_PUBLIC_DOMAIN) ||
|
||||
trimToNull(process.env.S3_PUBLIC_ENDPOINT) ||
|
||||
trimToNull(process.env.S3_PUBLIC_URL) ||
|
||||
trimToNull(process.env.S3_ENDPOINT)
|
||||
|
||||
return configured ? configured.replace(/\/+$/, '') : null
|
||||
}
|
||||
|
||||
function buildQiniuPublicBaseUrl(): string | null {
|
||||
const configured =
|
||||
trimToNull(process.env.QINIU_PUBLIC_DOMAIN) ||
|
||||
trimToNull(process.env.QINIU_PUBLIC_ENDPOINT) ||
|
||||
trimToNull(process.env.QINIU_PUBLIC_URL)
|
||||
|
||||
if (!configured) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(configured)) {
|
||||
return configured.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const useHttps = readBooleanEnv(process.env.QINIU_USE_HTTPS, true)
|
||||
return `${useHttps ? 'https' : 'http'}://${configured}`.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function publicBaseAlreadyContainsBucket(publicBaseUrl: string, bucketName: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(publicBaseUrl)
|
||||
const pathname = parsed.pathname.replace(/\/+$/, '')
|
||||
return pathname === `/${bucketName}`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getPublicObjectUrlBaseCandidates(): string[] {
|
||||
const bases = new Set<string>()
|
||||
|
||||
const s3PublicBaseUrl = buildS3PublicBaseUrl()
|
||||
const s3BucketName = trimToNull(process.env.S3_BUCKET) || 'furniture-3d-models'
|
||||
if (s3PublicBaseUrl) {
|
||||
bases.add(s3PublicBaseUrl)
|
||||
if (!publicBaseAlreadyContainsBucket(s3PublicBaseUrl, s3BucketName)) {
|
||||
bases.add(`${s3PublicBaseUrl}/${s3BucketName}`)
|
||||
}
|
||||
}
|
||||
|
||||
const qiniuPublicBaseUrl = buildQiniuPublicBaseUrl()
|
||||
if (qiniuPublicBaseUrl) {
|
||||
bases.add(qiniuPublicBaseUrl)
|
||||
}
|
||||
|
||||
return Array.from(bases).sort((left, right) => right.length - left.length)
|
||||
}
|
||||
|
||||
function readPublicObjectAccessKey(value: string): string | null {
|
||||
const normalized = trimToNull(value)
|
||||
if (!normalized) return null
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(normalized)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const base of getPublicObjectUrlBaseCandidates()) {
|
||||
let baseUrl: URL
|
||||
try {
|
||||
baseUrl = new URL(base)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (parsed.origin !== baseUrl.origin) {
|
||||
continue
|
||||
}
|
||||
|
||||
const basePath = baseUrl.pathname.replace(/\/+$/, '')
|
||||
if (
|
||||
basePath &&
|
||||
parsed.pathname !== basePath &&
|
||||
!parsed.pathname.startsWith(`${basePath}/`)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rawKey = parsed.pathname.slice(basePath.length).replace(/^\/+/, '')
|
||||
if (!rawKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
let decodedKey = rawKey
|
||||
try {
|
||||
decodedKey = decodeURIComponent(rawKey)
|
||||
} catch {
|
||||
// Keep original raw key and let normalizeObjectKey validate it.
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeObjectKey(decodedKey)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeObjectStorageKey(key: string): string {
|
||||
return normalizeObjectKey(key)
|
||||
}
|
||||
|
||||
export function buildStableObjectAccessUrl(key: string): string {
|
||||
return buildPrivateObjectAccessUrl(key)
|
||||
}
|
||||
|
||||
export function readObjectStorageKeyFromUrl(value: string): string | null {
|
||||
const objectKey =
|
||||
readPrivateObjectAccessKey(value) ?? readPublicObjectAccessKey(value)
|
||||
if (!objectKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeObjectKey(objectKey)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveObjectStorageUrlForClientAccess(url: string): string {
|
||||
const objectKey = readObjectStorageKeyFromUrl(url)
|
||||
return objectKey ? buildStableObjectAccessUrl(objectKey) : url
|
||||
}
|
||||
|
||||
export function getConfiguredStorageProvider(): StorageProvider {
|
||||
return STORAGE_PROVIDER
|
||||
}
|
||||
|
||||
export function getConfiguredStorageFilePathPrefix(): string {
|
||||
return FILE_PATH_PREFIX
|
||||
}
|
||||
@ -281,10 +281,6 @@ describe('storage object provider', () => {
|
||||
).toContain(
|
||||
'http://tevepd6od.hd-bkt.clouddn.com/prod-assets/models/table.glb?e=',
|
||||
)
|
||||
expect(mockQiniuPrivateDownloadUrl).toHaveBeenCalledWith(
|
||||
'http://tevepd6od.hd-bkt.clouddn.com',
|
||||
'prod-assets/models/table.glb',
|
||||
expect.any(Number),
|
||||
)
|
||||
expect(mockQiniuPrivateDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,19 +9,19 @@
|
||||
* @created 2025-12-12
|
||||
*/
|
||||
|
||||
import { createHmac } from 'node:crypto'
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import * as qiniu from 'qiniu'
|
||||
buildStableObjectAccessUrl as buildStableObjectAccessUrlImpl,
|
||||
getConfiguredStorageFilePathPrefix,
|
||||
getConfiguredStorageProvider,
|
||||
normalizeObjectStorageKey as normalizeObjectStorageKeyImpl,
|
||||
readObjectStorageKeyFromUrl as readObjectStorageKeyFromUrlImpl,
|
||||
resolveObjectStorageUrlForClientAccess as resolveObjectStorageUrlForClientAccessImpl,
|
||||
} from '@/lib/object-storage-url'
|
||||
|
||||
export type StorageProvider = 's3' | 'qiniu'
|
||||
export type StorageAccessMode = 'public' | 'private'
|
||||
|
||||
const PRIVATE_OBJECT_ROUTE_PATH = '/api/storage/object'
|
||||
|
||||
interface ObjectStorageAdapter {
|
||||
provider: StorageProvider
|
||||
accessMode: StorageAccessMode
|
||||
@ -111,45 +111,15 @@ function normalizeObjectKey(key: string): string {
|
||||
}
|
||||
|
||||
export function normalizeObjectStorageKey(key: string): string {
|
||||
return normalizeObjectKey(key)
|
||||
}
|
||||
|
||||
function buildPrivateObjectAccessUrl(key: string): string {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const params = new URLSearchParams({ key: objectKey })
|
||||
return `${PRIVATE_OBJECT_ROUTE_PATH}?${params.toString()}`
|
||||
return normalizeObjectStorageKeyImpl(key)
|
||||
}
|
||||
|
||||
export function buildStableObjectAccessUrl(key: string): string {
|
||||
return buildPrivateObjectAccessUrl(key)
|
||||
}
|
||||
|
||||
function readPrivateObjectAccessKey(value: string): string | null {
|
||||
const normalized = trimToNull(value)
|
||||
if (!normalized) return null
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'http://storage.local')
|
||||
if (parsed.pathname !== PRIVATE_OBJECT_ROUTE_PATH) {
|
||||
return null
|
||||
}
|
||||
|
||||
return trimToNull(parsed.searchParams.get('key') ?? undefined)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return buildStableObjectAccessUrlImpl(key)
|
||||
}
|
||||
|
||||
function getPublicObjectUrlBaseCandidates(): string[] {
|
||||
const storageAdapter = getStorageAdapter()
|
||||
const bases = new Set<string>()
|
||||
const publicEndpoint = storageAdapter.publicEndpoint.replace(/\/+$/, '')
|
||||
if (publicEndpoint) {
|
||||
bases.add(`${publicEndpoint}/${storageAdapter.bucketName}`)
|
||||
bases.add(publicEndpoint)
|
||||
}
|
||||
|
||||
return Array.from(bases)
|
||||
return []
|
||||
}
|
||||
|
||||
function readPublicObjectAccessKey(value: string): string | null {
|
||||
@ -211,17 +181,7 @@ function readPublicObjectAccessKey(value: string): string | null {
|
||||
}
|
||||
|
||||
export function readObjectStorageKeyFromUrl(value: string): string | null {
|
||||
const objectKey =
|
||||
readPrivateObjectAccessKey(value) ?? readPublicObjectAccessKey(value)
|
||||
if (!objectKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeObjectKey(objectKey)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return readObjectStorageKeyFromUrlImpl(value)
|
||||
}
|
||||
|
||||
function buildS3PublicBaseUrl(): string {
|
||||
@ -248,6 +208,16 @@ function publicBaseAlreadyContainsBucket(publicBaseUrl: string, bucketName: stri
|
||||
}
|
||||
}
|
||||
|
||||
type S3SdkModule = typeof import('@aws-sdk/client-s3')
|
||||
let s3SdkPromise: Promise<S3SdkModule> | null = null
|
||||
|
||||
function loadS3Sdk(): Promise<S3SdkModule> {
|
||||
if (!s3SdkPromise) {
|
||||
s3SdkPromise = import('@aws-sdk/client-s3')
|
||||
}
|
||||
return s3SdkPromise
|
||||
}
|
||||
|
||||
function createS3StorageAdapter(): ObjectStorageAdapter {
|
||||
const internalEndpoint = requireEnv('S3_ENDPOINT', trimToNull(process.env.S3_ENDPOINT))
|
||||
const publicEndpoint = buildS3PublicBaseUrl()
|
||||
@ -265,15 +235,25 @@ function createS3StorageAdapter(): ObjectStorageAdapter {
|
||||
const bucketName = process.env.S3_BUCKET || 'furniture-3d-models'
|
||||
const forcePathStyle = readBooleanEnv(process.env.S3_ENABLE_PATH_STYLE, true)
|
||||
|
||||
const s3Client = new S3Client({
|
||||
endpoint: internalEndpoint,
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
},
|
||||
forcePathStyle,
|
||||
})
|
||||
let s3ClientPromise: Promise<InstanceType<S3SdkModule['S3Client']>> | null = null
|
||||
|
||||
function getS3Client() {
|
||||
if (!s3ClientPromise) {
|
||||
s3ClientPromise = loadS3Sdk().then(({ S3Client }) =>
|
||||
new S3Client({
|
||||
endpoint: internalEndpoint,
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
},
|
||||
forcePathStyle,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return s3ClientPromise
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 's3',
|
||||
@ -283,6 +263,8 @@ function createS3StorageAdapter(): ObjectStorageAdapter {
|
||||
internalEndpoint,
|
||||
publicEndpoint,
|
||||
async uploadBuffer(buffer, key, contentType) {
|
||||
const { PutObjectCommand } = await loadS3Sdk()
|
||||
const s3Client = await getS3Client()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
@ -307,6 +289,8 @@ function createS3StorageAdapter(): ObjectStorageAdapter {
|
||||
return this.buildPublicUrl(key)
|
||||
},
|
||||
async objectExists(key) {
|
||||
const { HeadObjectCommand } = await loadS3Sdk()
|
||||
const s3Client = await getS3Client()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
try {
|
||||
await s3Client.send(
|
||||
@ -337,6 +321,8 @@ function createS3StorageAdapter(): ObjectStorageAdapter {
|
||||
}
|
||||
},
|
||||
async deleteObject(key) {
|
||||
const { DeleteObjectCommand } = await loadS3Sdk()
|
||||
const s3Client = await getS3Client()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: bucketName,
|
||||
@ -367,6 +353,16 @@ function buildQiniuPublicBaseUrl(): string {
|
||||
return `${useHttps ? 'https' : 'http'}://${configured}`.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
type QiniuSdkModule = typeof import('qiniu')
|
||||
let qiniuSdkPromise: Promise<QiniuSdkModule> | null = null
|
||||
|
||||
function loadQiniuSdk(): Promise<QiniuSdkModule> {
|
||||
if (!qiniuSdkPromise) {
|
||||
qiniuSdkPromise = import('qiniu')
|
||||
}
|
||||
return qiniuSdkPromise
|
||||
}
|
||||
|
||||
function getHttpStatusCode(error: unknown): number | null {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
@ -445,6 +441,36 @@ function isQiniuMissingObject(errorOrResponse: unknown): boolean {
|
||||
return errorText.includes('no such') || errorText.includes('not found')
|
||||
}
|
||||
|
||||
function encodeObjectKeyForPublicUrl(key: string): string {
|
||||
return normalizeObjectKey(key)
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
function buildQiniuPublicDownloadUrl(publicEndpoint: string, key: string): string {
|
||||
return `${publicEndpoint.replace(/\/+$/, '')}/${encodeObjectKeyForPublicUrl(key)}`
|
||||
}
|
||||
|
||||
function toQiniuUrlSafeBase64(input: Buffer): string {
|
||||
return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
}
|
||||
|
||||
function buildQiniuPrivateDownloadUrl(params: {
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
publicEndpoint: string
|
||||
key: string
|
||||
deadline: number
|
||||
}): string {
|
||||
const baseUrl = buildQiniuPublicDownloadUrl(params.publicEndpoint, params.key)
|
||||
const separator = baseUrl.includes('?') ? '&' : '?'
|
||||
const unsignedUrl = `${baseUrl}${separator}e=${params.deadline}`
|
||||
const digest = createHmac('sha1', params.secretKey).update(unsignedUrl).digest()
|
||||
const encodedDigest = toQiniuUrlSafeBase64(digest)
|
||||
return `${unsignedUrl}&token=${params.accessKey}:${encodedDigest}`
|
||||
}
|
||||
|
||||
function createQiniuStorageAdapter(): ObjectStorageAdapter {
|
||||
const accessKey = requireEnv('QINIU_ACCESS_KEY', trimToNull(process.env.QINIU_ACCESS_KEY))
|
||||
const secretKey = requireEnv('QINIU_SECRET_KEY', trimToNull(process.env.QINIU_SECRET_KEY))
|
||||
@ -457,16 +483,30 @@ function createQiniuStorageAdapter(): ObjectStorageAdapter {
|
||||
60,
|
||||
)
|
||||
|
||||
const mac = new qiniu.auth.digest.Mac(accessKey, secretKey)
|
||||
const qiniuConfig = new qiniu.conf.Config({ useHttpsDomain: useHttps })
|
||||
const formUploader = new qiniu.form_up.FormUploader(qiniuConfig)
|
||||
const bucketManager = new qiniu.rs.BucketManager(mac, qiniuConfig)
|
||||
type QiniuContext = {
|
||||
sdk: QiniuSdkModule
|
||||
mac: InstanceType<QiniuSdkModule['auth']['digest']['Mac']>
|
||||
formUploader: InstanceType<QiniuSdkModule['form_up']['FormUploader']>
|
||||
bucketManager: InstanceType<QiniuSdkModule['rs']['BucketManager']>
|
||||
}
|
||||
|
||||
function buildUploadToken(objectKey: string): string {
|
||||
const putPolicy = new qiniu.rs.PutPolicy({
|
||||
scope: `${bucketName}:${objectKey}`,
|
||||
})
|
||||
return putPolicy.uploadToken(mac)
|
||||
let qiniuContextPromise: Promise<QiniuContext> | null = null
|
||||
|
||||
function getQiniuContext(): Promise<QiniuContext> {
|
||||
if (!qiniuContextPromise) {
|
||||
qiniuContextPromise = loadQiniuSdk().then((sdk) => {
|
||||
const mac = new sdk.auth.digest.Mac(accessKey, secretKey)
|
||||
const qiniuConfig = new sdk.conf.Config({ useHttpsDomain: useHttps })
|
||||
return {
|
||||
sdk,
|
||||
mac,
|
||||
formUploader: new sdk.form_up.FormUploader(qiniuConfig),
|
||||
bucketManager: new sdk.rs.BucketManager(mac, qiniuConfig),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return qiniuContextPromise
|
||||
}
|
||||
|
||||
return {
|
||||
@ -477,12 +517,16 @@ function createQiniuStorageAdapter(): ObjectStorageAdapter {
|
||||
internalEndpoint: `qiniu://${bucketName}`,
|
||||
publicEndpoint,
|
||||
async uploadBuffer(buffer, key, contentType) {
|
||||
const { sdk, mac, formUploader } = await getQiniuContext()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const putExtra = new qiniu.form_up.PutExtra()
|
||||
const putPolicy = new sdk.rs.PutPolicy({
|
||||
scope: `${bucketName}:${objectKey}`,
|
||||
})
|
||||
const putExtra = new sdk.form_up.PutExtra()
|
||||
putExtra.mimeType = contentType
|
||||
|
||||
const response = await formUploader.put(
|
||||
buildUploadToken(objectKey),
|
||||
putPolicy.uploadToken(mac),
|
||||
objectKey,
|
||||
buffer,
|
||||
putExtra,
|
||||
@ -497,22 +541,29 @@ function createQiniuStorageAdapter(): ObjectStorageAdapter {
|
||||
buildPublicUrl(key) {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
if (privateBucket) {
|
||||
return buildPrivateObjectAccessUrl(objectKey)
|
||||
return buildStableObjectAccessUrl(objectKey)
|
||||
}
|
||||
|
||||
return bucketManager.publicDownloadUrl(publicEndpoint, objectKey)
|
||||
return buildQiniuPublicDownloadUrl(publicEndpoint, objectKey)
|
||||
},
|
||||
buildDirectReadUrl(key) {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
if (!privateBucket) {
|
||||
return bucketManager.publicDownloadUrl(publicEndpoint, objectKey)
|
||||
return buildQiniuPublicDownloadUrl(publicEndpoint, objectKey)
|
||||
}
|
||||
|
||||
const deadline =
|
||||
Math.floor(Date.now() / 1000) + privateUrlExpiresSeconds
|
||||
return bucketManager.privateDownloadUrl(publicEndpoint, objectKey, deadline)
|
||||
return buildQiniuPrivateDownloadUrl({
|
||||
accessKey,
|
||||
secretKey,
|
||||
publicEndpoint,
|
||||
key: objectKey,
|
||||
deadline,
|
||||
})
|
||||
},
|
||||
async objectExists(key) {
|
||||
const { bucketManager } = await getQiniuContext()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
try {
|
||||
const response = await bucketManager.stat(bucketName, objectKey)
|
||||
@ -531,6 +582,7 @@ function createQiniuStorageAdapter(): ObjectStorageAdapter {
|
||||
}
|
||||
},
|
||||
async deleteObject(key) {
|
||||
const { bucketManager } = await getQiniuContext()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
try {
|
||||
const response = await bucketManager.delete(bucketName, objectKey)
|
||||
@ -631,13 +683,12 @@ export function buildSharedObjectAccessUrl(params: {
|
||||
}
|
||||
|
||||
export function resolveObjectStorageUrlForServerAccess(url: string): string {
|
||||
const objectKey = readPrivateObjectAccessKey(url)
|
||||
const objectKey = readObjectStorageKeyFromUrl(url)
|
||||
return objectKey ? createObjectReadUrl(objectKey) : url
|
||||
}
|
||||
|
||||
export function resolveObjectStorageUrlForClientAccess(url: string): string {
|
||||
const objectKey = readObjectStorageKeyFromUrl(url)
|
||||
return objectKey ? buildStableObjectAccessUrl(objectKey) : url
|
||||
return resolveObjectStorageUrlForClientAccessImpl(url)
|
||||
}
|
||||
|
||||
export async function doesObjectExist(key: string): Promise<boolean> {
|
||||
@ -663,7 +714,7 @@ export const doesS3ObjectExist = doesObjectExist
|
||||
export const deleteFromS3 = deleteObject
|
||||
|
||||
export function getStorageProvider(): StorageProvider {
|
||||
return getStorageAdapter().provider
|
||||
return getConfiguredStorageProvider() as StorageProvider
|
||||
}
|
||||
|
||||
export function getStorageAccessMode(): StorageAccessMode {
|
||||
@ -683,7 +734,7 @@ export function getStoragePublicEndpoint(): string {
|
||||
}
|
||||
|
||||
export function getStorageFilePathPrefix(): string {
|
||||
return getStorageAdapter().filePathPrefix
|
||||
return getConfiguredStorageFilePathPrefix()
|
||||
}
|
||||
|
||||
export function getStorageAllowedPublicOrigins(): string[] {
|
||||
|
||||
@ -270,6 +270,10 @@ class ProductWriteService {
|
||||
? (await this.ensureCategoryIdByName(input.category)).id
|
||||
: null
|
||||
const modelNumber = normalizeProductIdentityCode(input.modelNumber)
|
||||
const modelUrl = normalizeNullableProductAssetUrl(input.modelUrl)
|
||||
if (!modelUrl) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'CAD 模型地址不能为空')
|
||||
}
|
||||
await releaseSkuCodeFromDeletedRecords(`${modelNumber}-CAD`)
|
||||
|
||||
const result = await this.repository.createProductFromCAD({
|
||||
@ -277,7 +281,7 @@ class ProductWriteService {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
visibility: input.visibility,
|
||||
modelUrl: normalizeNullableProductAssetUrl(input.modelUrl),
|
||||
modelUrl,
|
||||
sourceFileName: input.sourceFileName,
|
||||
categoryId,
|
||||
userId: ctx.user.id,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { hashPassword, validatePasswordStrength, verifyPassword } from '@/lib/password'
|
||||
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types'
|
||||
import { DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { BusinessAccountType, EmployeeSourceStatus, Role, UserStatus } from '@prisma/client'
|
||||
import { departmentRepository } from '../repositories/department.repository'
|
||||
@ -91,7 +91,7 @@ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const NAME_REGEX = /^[\u4e00-\u9fa5a-zA-Z0-9_]{2,20}$/
|
||||
const GENERIC_LOGIN_ERROR_MESSAGE = '账号或密码错误'
|
||||
const BUSINESS_ROLE_SET = new Set<string>(
|
||||
BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value),
|
||||
DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value),
|
||||
)
|
||||
|
||||
function normalizeEmail(email: string): string {
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { BUSINESS_ACCOUNT_TYPE_OPTIONS, normalizeBusinessRoles } from '@/lib/business-account-types'
|
||||
import {
|
||||
DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS,
|
||||
normalizeBusinessRoles,
|
||||
} from '@/lib/business-account-types'
|
||||
import { normalizeUserRole } from '@/lib/role-scope'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { BusinessAccountType, Role } from '@prisma/client'
|
||||
|
||||
const LEGACY_DESIGNER_MESSAGE = '设计师已迁移为业务类型:设计'
|
||||
const BUSINESS_ROLE_SET = new Set(BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value))
|
||||
const BUSINESS_ROLE_SET = new Set(
|
||||
DATABASE_BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value),
|
||||
)
|
||||
|
||||
function rejectLegacyDesignerInput(): never {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, LEGACY_DESIGNER_MESSAGE)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user