import { getOssSignatureApi, createMintOrderApi, uploadMaterialApi, bindAssetMaterialsApi } from '@/utils/api.js' import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js' import { buildCastloveFormSnapshot } from '@/utils/castloveMintForm.js' function uploadFileToOss(tempFilePath, ossData) { return new Promise((resolve, reject) => { const fileName = `${Date.now()}.jpg` uni.uploadFile({ url: ossData.host, filePath: tempFilePath, name: 'file', formData: { key: ossData.dir + fileName, policy: ossData.policy, success_action_status: '200', 'x-oss-credential': ossData.x_oss_credential, 'x-oss-date': ossData.x_oss_date, 'x-oss-security-token': ossData.security_token, 'x-oss-signature': ossData.signature, 'x-oss-signature-version': ossData.x_oss_signature_version, }, success: (res) => { if (res.statusCode === 200 || res.statusCode === 204) { resolve(`${ossData.host}/${ossData.dir}${fileName}`) } else { reject(new Error(`上传失败 ${res.statusCode}`)) } }, fail: reject, }) }) } /** * 上传文件到 OSS 并返回文件大小和 hash(用于注册素材) */ function uploadFileToOssWithInfo(tempFilePath, ossData) { return new Promise((resolve, reject) => { console.log('[craftMintSubmit] uploadFileToOssWithInfo tempFilePath:', tempFilePath) const fileName = `${Date.now()}.jpg` uni.uploadFile({ url: resolveH5OssPostUrl(ossData.host), filePath: tempFilePath, name: 'file', formData: { key: ossData.dir + fileName, policy: ossData.policy, success_action_status: '200', 'x-oss-credential': ossData.x_oss_credential, 'x-oss-date': ossData.x_oss_date, 'x-oss-security-token': ossData.security_token, 'x-oss-signature': ossData.signature, 'x-oss-signature-version': ossData.x_oss_signature_version, }, success: async (res) => { console.log('[craftMintSubmit] uploadFileToOssWithInfo statusCode:', res.statusCode) if (res.statusCode === 200 || res.statusCode === 204) { const url = `${ossData.host}/${ossData.dir}${fileName}` // 获取文件大小 let size = 0 try { const fileInfo = await new Promise((res2, rej) => { uni.getFileInfo({ filePath: tempFilePath, success: (info) => res2(info.size), fail: rej }) }) size = fileInfo || 0 } catch (e) { console.warn('[craftMintSubmit] getFileInfo failed', e) } // 计算文件 hash const hash = await computeHashFromPath(tempFilePath) console.log('[craftMintSubmit] uploadFileToOssWithInfo hash:', hash ? hash.substring(0, 10) + '...' : 'empty') resolve({ url, size, hash }) } else { reject(new Error(`上传失败 ${res.statusCode}`)) } }, fail: (err) => { console.error('[craftMintSubmit] uploadFileToOssWithInfo fail:', err) reject(err) }, }) }) } /** * 纯 JavaScript SHA256 实现(用于 App 等不支持 crypto.subtle 的环境) */ function sha256Sync(str) { // SHA256 实现 const rotateRight = (n, s) => (n >>> s) | ((n << (32 - s)) >>> 0) const choice = (x, y, z) => (x & y) ^ (~x & z) const majority = (x, y, z) => (x & y) ^ (x & z) ^ (y & z) const maj = (x, y, z) => (x & y) ^ (x & z) ^ (y & z) const ch = (x, y, z) => (x & y) ^ (~x & z) const K = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0x49b40821, 0xfef9e05c, 0x4fd9c4a7, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 ] // 转换为 bytes const msgBytes = [] for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i) if (code < 0x80) { msgBytes.push(code) } else if (code < 0x800) { msgBytes.push(0xc0 | (code >> 6)) msgBytes.push(0x80 | (code & 0x3f)) } else if (code < 0xd800 || code >= 0xe000) { msgBytes.push(0xe0 | (code >> 12)) msgBytes.push(0x80 | ((code >> 6) & 0x3f)) msgBytes.push(0x80 | (code & 0x3f)) } else { const cp = 0x10000 + ((code - 0xd800) << 10) | (str.charCodeAt(++i) - 0xdc00) msgBytes.push(0xf0 | (cp >> 18)) msgBytes.push(0x80 | ((cp >> 12) & 0x3f)) msgBytes.push(0x80 | ((cp >> 6) & 0x3f)) msgBytes.push(0x80 | (cp & 0x3f)) } } // Padding const msgLen = msgBytes.length const bitLen = msgLen * 8 msgBytes.push(0x80) while ((msgBytes.length % 64) !== 56) msgBytes.push(0) for (let i = 7; i >= 0; i--) msgBytes.push((bitLen >>> (i * 8)) & 0xff) // Initialize hash values let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19 // Process blocks for (let chunk = 0; chunk < msgBytes.length / 64; chunk++) { const w = new Array(64) for (let j = 0; j < 16; j++) { w[j] = (msgBytes[chunk * 64 + j * 4] << 24) | (msgBytes[chunk * 64 + j * 4 + 1] << 16) | (msgBytes[chunk * 64 + j * 4 + 2] << 8) | msgBytes[chunk * 64 + j * 4 + 3] } for (let j = 16; j < 64; j++) { const s0 = rotateRight(w[j - 15], 7) ^ rotateRight(w[j - 15], 18) ^ (w[j - 15] >>> 3) const s1 = rotateRight(w[j - 2], 17) ^ rotateRight(w[j - 2], 19) ^ (w[j - 2] >>> 10) w[j] = (w[j - 16] + s0 + w[j - 7] + s1) >>> 0 } let [ah0, ah1, ah2, ah3, ah4, ah5, ah6, ah7] = [h0, h1, h2, h3, h4, h5, h6, h7] for (let j = 0; j < 64; j++) { const S1 = rotateRight(ah6, 6) ^ rotateRight(ah6, 11) ^ rotateRight(ah6, 25) const ch2 = ch(ah6, ah5, ah4) const temp1 = (ah7 + S1 + ch2 + K[j] + w[j]) >>> 0 const S0 = rotateRight(ah0, 2) ^ rotateRight(ah0, 13) ^ rotateRight(ah0, 22) const maj2 = maj(ah0, ah1, ah2) const temp2 = (S0 + maj2) >>> 0 ah7 = (ah6 + temp1) >>> 0 ah6 = (ah5 + temp1) >>> 0 ah5 = (ah4 + temp1) >>> 0 ah4 = (ah3 + temp1) >>> 0 ah3 = (ah2 + temp1) >>> 0 ah2 = (ah1 + temp1) >>> 0 ah1 = (ah0 + temp1) >>> 0 ah0 = (temp1 + temp2) >>> 0 } h0 = (h0 + ah0) >>> 0; h1 = (h1 + ah1) >>> 0; h2 = (h2 + ah2) >>> 0; h3 = (h3 + ah3) >>> 0 h4 = (h4 + ah4) >>> 0; h5 = (h5 + ah5) >>> 0; h6 = (h6 + ah6) >>> 0; h7 = (h7 + ah7) >>> 0 } const toHex = (n) => n.toString(16).padStart(8, '0') return toHex(h0) + toHex(h1) + toHex(h2) + toHex(h3) + toHex(h4) + toHex(h5) + toHex(h6) + toHex(h7) } /** * 计算文件 SHA256 哈希(使用 Web Crypto API) */ async function computeFileHash(file) { console.log('[craftMintSubmit] computeFileHash start, file type:', typeof file, file instanceof Blob ? `Blob(size=${file.size})` : '') try { if (typeof crypto !== 'undefined' && crypto.subtle) { const buffer = await file.arrayBuffer() console.log('[craftMintSubmit] computeFileHash arrayBuffer length:', buffer.byteLength) const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') console.log('[craftMintSubmit] computeFileHash result:', hash.substring(0, 20) + '...') return hash } else { console.warn('[craftMintSubmit] computeFileHash: crypto.subtle not available') } } catch (e) { console.error('[craftMintSubmit] computeFileHash failed:', e) } // Fallback: 返回空字符串,后端会重新计算 return '' } /** * 从文件路径读取内容并计算 hash(支持各端) */ async function computeHashFromPath(filePath) { console.log('[craftMintSubmit] computeHashFromPath start, path:', filePath ? filePath.substring(0, 80) : 'empty') try { // 处理 data:URL 格式 if (filePath && filePath.startsWith('data:')) { console.log('[craftMintSubmit] computeHashFromPath data:URL format') try { const base64Data = filePath.split(',')[1] || '' const binaryStr = atob(base64Data) // 优先用 crypto.subtle,失败则用纯JS if (typeof crypto !== 'undefined' && crypto.subtle) { const bytes = new Uint8Array(binaryStr.length) for (let i = 0; i < binaryStr.length; i++) { bytes[i] = binaryStr.charCodeAt(i) } const hashBuffer = await crypto.subtle.digest('SHA-256', bytes) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') console.log('[craftMintSubmit] computeHashFromPath data:URL hash:', hash.substring(0, 20) + '...') return hash } else { // Fallback 纯JS SHA256 const hash = sha256Sync(binaryStr) console.log('[craftMintSubmit] computeHashFromPath data:URL sha256Sync hash:', hash.substring(0, 20) + '...') return hash } } catch (e) { console.error('[craftMintSubmit] data:URL hash failed:', e) } return '' } let content = '' // #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ console.log('[craftMintSubmit] computeHashFromPath in miniprogram') try { const fs = uni.getFileSystemManager() content = await new Promise((resolve, reject) => { fs.readFile({ filePath: filePath, encoding: 'base64', success: (res) => { console.log('[craftMintSubmit] fs.readFile success, data length:', res.data?.length) resolve(res.data) }, fail: (err) => { console.error('[craftMintSubmit] fs.readFile fail:', err) reject(err) } }) }) } catch (e) { console.error('[craftMintSubmit] 小程序读取文件失败:', e) } // #endif // #ifdef APP-PLUS if (!content) { console.log('[craftMintSubmit] computeHashFromPath in app-plus') try { content = await new Promise((resolve, reject) => { plus.io.resolveLocalFileSystemURL(filePath, (entry) => { entry.file((file) => { const reader = new plus.io.FileReader() reader.onloadend = (e) => { console.log('[craftMintSubmit] FileReader onloadend, result length:', e.target.result?.length) resolve(e.target.result.split(',')[1] || '') } reader.onerror = (e) => { console.error('[craftMintSubmit] FileReader error:', e) reject(e) } reader.readAsDataURL(file) }, (err) => { console.error('[craftMintSubmit] getFile error:', err) reject(err) }) }, (err) => { console.error('[craftMintSubmit] resolveLocalFileSystemURL error:', err) reject(err) }) }) } catch (e) { console.error('[craftMintSubmit] App读取文件失败:', e) } } // #endif console.log('[craftMintSubmit] computeHashFromPath content length:', content?.length || 0) console.log('[craftMintSubmit] computeHashFromPath crypto:', typeof crypto, crypto ? 'exists' : 'null', ', subtle:', crypto?.subtle ? 'exists' : 'null') if (content && typeof crypto !== 'undefined' && crypto && crypto.subtle) { try { // 将 base64 转换为 ArrayBuffer 并计算 hash const binary = atob(content) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } const hashBuffer = await crypto.subtle.digest('SHA-256', bytes) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') console.log('[craftMintSubmit] computeHashFromPath hash:', hash.substring(0, 20) + '...') return hash } catch (e) { console.error('[craftMintSubmit] hash计算失败:', e) } } else { // Fallback: 使用纯JS SHA256 实现 (App 环境 crypto.subtle 可能不可用) try { console.log('[craftMintSubmit] computeHashFromPath 使用纯JS SHA256 fallback, content length:', content.length) // content 是 base64 字符串,需要先解码为 binary string const binaryStr = atob(content) const hash = sha256Sync(binaryStr) console.log('[craftMintSubmit] computeHashFromPath sha256Sync hash:', hash.substring(0, 20) + '...') return hash } catch (e) { console.error('[craftMintSubmit] SHA256 fallback failed:', e) } } } catch (e) { console.error('[craftMintSubmit] computeHashFromPath failed:', e) } console.log('[craftMintSubmit] computeHashFromPath return empty') return '' } /** * 获取文件 MIME 类型 */ function getFileMimeType(filePath) { const ext = filePath?.toLowerCase().split('.').pop() if (ext === 'png') return 'image/png' return 'image/jpeg' } /** * 上传图片到 OSS 并注册为素材 * @param {string} imagePath - 图片路径或 base64 * @param {object} ossData - OSS 签名数据 * @param {string} originalName - 原始文件名 * @param {string} materialType - 素材类型 (main/bg/sticker/patch 等) * @returns {Promise<{ ossKey: string, materialId: number }>} */ async function uploadImageAndRegisterMaterial(imagePath, ossData, originalName, materialType) { console.log('[craftMintSubmit] uploadImageAndRegisterMaterial', { materialType, imagePath: String(imagePath || '').substring(0, 50) }) // 1. 上传图片到 OSS let imageUrl = imagePath let uploadFileSize = 0 let uploadHash = '' if (!imagePath.startsWith('http')) { // #ifdef H5 if (imagePath.startsWith('data:')) { console.log('[craftMintSubmit] H5 base64 upload') // H5: data:URL 需要手动转换 为 Blob const base64Data = imagePath.split(',')[1] || '' const binaryStr = atob(base64Data) const bytes = new Uint8Array(binaryStr.length) for (let i = 0; i < binaryStr.length; i++) { bytes[i] = binaryStr.charCodeAt(i) } const blob = new Blob([bytes], { type: 'image/jpeg' }) uploadFileSize = blob.size uploadHash = await computeFileHash(blob) console.log('[craftMintSubmit] H5 base64 hash computed:', uploadHash.substring(0, 20) + '...') const fd = new FormData() const fileName = `${Date.now()}.jpg` fd.append('key', ossData.dir + fileName) fd.append('policy', ossData.policy) fd.append('success_action_status', '200') fd.append('x-oss-credential', ossData.x_oss_credential) fd.append('x-oss-date', ossData.x_oss_date) fd.append('x-oss-security-token', ossData.security_token) fd.append('x-oss-signature', ossData.signature) fd.append('x-oss-signature-version', ossData.x_oss_signature_version) fd.append('file', blob, fileName) const res = await fetch(resolveH5OssPostUrl(ossData.host), { method: 'POST', body: fd }) console.log('[craftMintSubmit] OSS upload res:', res.status, res.ok) if (!res.ok && res.status !== 204) { throw new Error('上传失败') } imageUrl = `${ossData.host}/${ossData.dir}${fileName}` console.log('[craftMintSubmit] H5 base64 upload complete, hash:', uploadHash.substring(0, 20) + '...') } else { console.log('[craftMintSubmit] 小程序/非H5上传') const uploadResult = await uploadFileToOssWithInfo(imagePath, ossData) imageUrl = uploadResult.url uploadFileSize = uploadResult.size || 0 uploadHash = uploadResult.hash || '' } // #endif // #ifndef H5 const uploadResult = await uploadFileToOssWithInfo(imagePath, ossData) imageUrl = uploadResult.url uploadFileSize = uploadResult.size || 0 uploadHash = uploadResult.hash || '' // #endif } console.log('[craftMintSubmit] imageUrl:', imageUrl, 'fileSize:', uploadFileSize, 'hash:', uploadHash.substring(0, 20)) // 2. 构建 oss_key(去掉 host 前缀) const ossKey = imageUrl.replace(/^https?:\/\/[^/]+\/(.+)$/, '$1') // 3. 获取 MIME 类型 const mimeType = getFileMimeType(imagePath) // 4. 注册素材到后端 console.log('[craftMintSubmit] uploadMaterialApi ossKey:', ossKey, 'mimeType:', mimeType, 'fileSize:', uploadFileSize) const materialRes = await uploadMaterialApi({ oss_key: ossKey, original_name: originalName || `${materialType}.jpg`, file_size: uploadFileSize, mime_type: mimeType, hash: uploadHash, material_type: materialType, }) console.log('[craftMintSubmit] uploadMaterialApi response:', materialRes) if (!materialRes || materialRes.code !== 200 || !materialRes.data) { throw new Error(materialRes?.message || '素材注册失败') } const materialId = materialRes.data.material_id return { ossKey, materialId } } /** * 铸爱确认页:上传选中图并创建铸造订单(支持多素材) * @param {{ imagePath: string, bgImagePath?: string, formData: object }} opts */ export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData }) { console.log('[craftMintSubmit] start', { imagePath: String(imagePath || '').trim().substring(0, 50), bgImagePath: !!bgImagePath, formDataKeys: Object.keys(formData || {}) }) const path = String(imagePath || '').trim() if (!path) { throw new Error('缺少作品图片') } // 1. 获取 OSS 签名 console.log('[craftMintSubmit] 1. 获取 OSS 签名') const signRes = await getOssSignatureApi('asset') console.log('[craftMintSubmit] OSS 签名响应:', signRes) if (!signRes || signRes.code !== 200 || !signRes.data) { throw new Error(signRes?.message || '获取签名失败') } const ossData = signRes.data const orderId = ossData.order_id || '' const snap = buildCastloveFormSnapshot({ nftInfo: formData.info || formData.nftInfo || '', materialTypes: formData.materialTypes || ['粉丝自制'], materialTypeIndex: formData.materialTypeIndex ?? 0, pageName: formData.craft_name || formData.typeName || '', uploadedImage: path, uploadedImageBase64: formData.imageBase64 || '', }) console.log('[craftMintSubmit] snap:', { name: snap.name, tags: snap.tags, isLenticular: snap.tags?.includes('craft:lenticular') }) const isLenticular = Array.isArray(snap.tags) && snap.tags.includes('craft:lenticular') console.log('[craftMintSubmit] isLenticular:', isLenticular) // 2. 上传主图并注册为主素材 console.log('[craftMintSubmit] 2. 上传主图并注册') const mainResult = await uploadImageAndRegisterMaterial(path, ossData, originalFileName(path), 'main') console.log('[craftMintSubmit] mainResult:', mainResult) // 3. 如果是光栅卡,上传背景图并注册为 bg 素材 let bgMaterialId = null if (isLenticular && bgImagePath) { const bgPath = String(bgImagePath || '').trim() if (bgPath) { console.log('[craftMintSubmit] 3. 上传背景图并注册') bgMaterialId = (await uploadImageAndRegisterMaterial(bgPath, ossData, originalFileName(bgPath), 'bg')).materialId } } // 4. 构建 material_url(使用主素材 oss_key 作为封面) // 后端铸造时会自动使用此 URL 生成封面图 const materialUrl = mainResult.ossKey // 5. 创建铸造订单(后端自动扣除水晶消耗) console.log('[craftMintSubmit] 5. 创建铸造订单', { orderId, materialUrl }) const orderData = { order_id: orderId, name: snap.name, material_url: materialUrl, description: snap.description || '', grade: snap.grade ?? 0, tags: Array.isArray(snap.tags) ? snap.tags : [], material_type: snap.material_type, info: snap.info || '', } console.log('[craftMintSubmit] orderData:', orderData) const response = await createMintOrderApi(orderData) console.log('[craftMintSubmit] createMintOrderApi 响应:', response) if (!response || response.code !== 200) { throw new Error(response?.message || '创建订单失败') } const assetId = response.data?.asset?.asset_id || response.data?.asset_id || response.data?.assetId || '' if (!assetId) { throw new Error('创建订单成功但未返回资产 ID') } console.log('[craftMintSubmit] assetId:', assetId) // 6. 绑定多素材到资产 const materialsToBind = [ { material_id: mainResult.materialId, material_type: 'main', layer_order: 0 } ] if (bgMaterialId) { materialsToBind.push({ material_id: bgMaterialId, material_type: 'bg', layer_order: 1 }) } console.log('[craftMintSubmit] 6. 绑定多素材:', materialsToBind) try { const bindRes = await bindAssetMaterialsApi(assetId, materialsToBind) console.log('[craftMintSubmit] bindRes:', bindRes) if (!bindRes || bindRes.code !== 200) { console.warn('[craftMintSubmit] bind materials failed, but mint order created:', bindRes?.message) } } catch (e) { console.warn('[craftMintSubmit] bind materials error:', e) // 不阻断铸造流程,资产已创建成功 } // 7. 构建 NFT 数据并跳转 const nftData = { image: `${ossData.host}/${mainResult.ossKey}`, // 完整OSS URL name: snap.name, description: snap.description || '', material_type: snap.material_type, tags: snap.tags, order_id: orderId, asset_id: assetId, info: snap.info, event: snap.info, } // 如果是光栅卡,存储背景图路径供 success 页面展示 if (isLenticular && bgImagePath) { nftData.bg_image = bgImagePath nftData.is_lenticular = true } uni.setStorageSync('temp_nft_data', JSON.stringify(nftData)) uni.removeStorageSync('castlove_form_data') console.log('[craftMintSubmit] 完成') return { imageUrl: mainResult.ossKey, orderId } } /** * 从文件路径提取原始文件名 */ function originalFileName(filePath) { if (!filePath) return '' try { if (filePath.startsWith('data:')) { return 'image.jpg' } return filePath.split('/').pop() || 'image.jpg' } catch { return 'image.jpg' } }