fix(storage): 打通对象存储边界与生产构建

This commit is contained in:
WuYongJie 2026-06-11 01:41:47 +08:00
parent 2f02d72565
commit 1d4e8cffc2
14 changed files with 432 additions and 109 deletions

View File

@ -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}

View File

@ -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}

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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)
}

View File

@ -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'

View File

@ -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([

View 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
}

View File

@ -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()
})
})

View File

@ -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[] {

View File

@ -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,

View File

@ -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 {

View File

@ -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)