topfans/frontend/utils/craftMintSubmit.js
2026-05-17 19:03:34 +08:00

572 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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