topfans/frontend/utils/laser-card/aliyunPortraitUni.js

558 lines
17 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.

/**
* uni-app App 端可用OSS Post/Put + 签名 GET URL直连抠图默认 IVPD SegmentImage另有 imageseg SegmentHDBodyPOP不依赖 ali-oss / Node http
* 签名使用 Web CryptoHMAC-SHA1避免额外依赖 crypto-js需安全上下文localhost / HTTPS且存在 globalThis.crypto.subtle。
*/
const _textEnc = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
function utf8StringToBase64(str) {
if (_textEnc) {
const bytes = _textEnc.encode(str)
let bin = ''
for (let i = 0; i < bytes.byteLength; i++) {
bin += String.fromCharCode(bytes[i])
}
return btoa(bin)
}
return btoa(unescape(encodeURIComponent(str)))
}
async function hmacSha1Base64(message, secretKey) {
const subtle = globalThis.crypto && globalThis.crypto.subtle
if (!subtle || !_textEnc) {
throw new Error(
'当前环境无法计算 OSS/阿里云签名:需要支持 Web Crypto请在 localhost 或 HTTPS 下打开,并确保非禁用 crypto.subtle 的 WebView'
)
}
const key = await subtle.importKey(
'raw',
_textEnc.encode(secretKey),
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
)
const sig = await subtle.sign('HMAC', key, _textEnc.encode(message))
const bytes = new Uint8Array(sig)
let bin = ''
for (let i = 0; i < bytes.byteLength; i++) {
bin += String.fromCharCode(bytes[i])
}
return btoa(bin)
}
/** RAM 密钥常见复制问题首尾空格、BOM、零宽字符、Windows 多余 \\r */
function normalizeAccessKeyPart(s) {
return String(s || '')
.trim()
.replace(/^\uFEFF/, '')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '')
.trim()
}
function percentEncode(str) {
if (str === undefined || str === null) {
return ''
}
return encodeURIComponent(String(str))
.replace(/!/g, '%21')
.replace(/'/g, '%27')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/\*/g, '%2A')
}
function pickHttpImageUrl(obj, depth = 0) {
if (!obj || depth > 10) return null
if (typeof obj === 'string' && /^https?:\/\//i.test(obj) && /\.(png|jpg|jpeg|webp)/i.test(obj)) {
return obj
}
if (typeof obj !== 'object') return null
for (const v of Object.values(obj)) {
const u = pickHttpImageUrl(v, depth + 1)
if (u) return u
}
return null
}
/** 递归查找 oss://bucket/object 形式 URI */
function pickOssUriString(obj, depth = 0) {
if (!obj || depth > 10) return null
if (typeof obj === 'string' && /^oss:\/\//i.test(obj)) {
if (parseOssUri(obj)) {
return obj
}
}
if (typeof obj !== 'object') return null
for (const v of Object.values(obj)) {
const u = pickOssUriString(v, depth + 1)
if (u) return u
}
return null
}
function parseOssUri(uri) {
const s = String(uri || '').trim()
const m = /^oss:\/\/([^/]+)\/(.+)$/i.exec(s)
if (!m) return null
return { bucket: m[1], objectKey: m[2] }
}
/** PostObject 的目标地址(仅桶根、无 object 路径);失败 errMsg 常只有这一串 URL */
export function looksLikeBareOssEndpointUrl(s) {
const t = String(s || '').trim()
if (!/^https?:\/\/[^/\s]+\.oss-[a-z0-9-]+\.aliyuncs\.com\/?$/i.test(t)) {
return false
}
try {
const u = new URL(t)
if (!/^https?:$/i.test(u.protocol)) return false
const segs = u.pathname.split('/').filter(Boolean)
return segs.length === 0
} catch {
return true
}
}
/** 仅一行 OSS 根域名错误时,转成可操作的说明(依赖 manifest domainWhiteList */
export function humanizeIfBareOssUploadErr(msg) {
const t = String(msg || '').trim()
if (!looksLikeBareOssEndpointUrl(t)) {
return String(msg || '')
}
const hostOnly = t.replace(/^https?:\/\//i, '').replace(/\/$/, '')
return (
`OSS 上传失败uni-app 常因未配置「域名白名单」拦截 uploadFile控制台只显示请求 URL\n\n请在 manifest.json → app-plus → networkSecurity → domainWhiteList 中加入(注意 https\n• https://${hostOnly}\n• IVPDhttps://ivpd.<地域>.aliyuncs.com与 IMM_REGION 一致,如 cn-shanghai\n以及抠图结果下载域名(若与上面不同)。\n保存后必须重新制作自定义调试基座再运行。\n\n原文:${t}`
)
}
/** 排除仅桶根域名、无对象路径的「伪 URL」IMM 偶发只回 Host */
function isLikelyObjectHttpUrl(url) {
try {
const u = new URL(String(url).trim())
if (!/^https?:$/i.test(u.protocol)) {
return false
}
const parts = u.pathname.split('/').filter(Boolean)
return parts.length >= 1
} catch {
return false
}
}
function ossHost(bucket, ossRegionId) {
const r = ossRegionId.replace(/^oss-/, '')
return `${bucket}.oss-${r}.aliyuncs.com`
}
/** objectKey 含路径时用分段编码路径 */
function ossObjectPath(objectKey) {
return objectKey
.split('/')
.map((seg) => encodeURIComponent(seg))
.join('/')
}
/** 私有读:签名 GET URL与 Put 后给分割接口用的 URL 一致) */
export async function ossPresignedObjectUrl({
bucket,
ossRegionId,
accessKeyId,
accessKeySecret,
securityToken,
objectKey,
expiresSec = 3600
}) {
const ak = normalizeAccessKeyPart(accessKeyId)
const sk = normalizeAccessKeyPart(accessKeySecret)
const host = ossHost(bucket, ossRegionId)
const path = ossObjectPath(objectKey)
const resource = `/${bucket}/${objectKey}`
const expires = Math.floor(Date.now() / 1000) + expiresSec
const stringToSignGet = `GET\n\n\n${expires}\n${resource}`
const sigGet = await hmacSha1Base64(stringToSignGet, sk)
let qs = `OSSAccessKeyId=${percentEncode(ak)}&Expires=${expires}&Signature=${percentEncode(sigGet)}`
if (securityToken) {
qs += `&security-token=${percentEncode(securityToken)}`
}
return `https://${host}/${path}?${qs}`
}
/**
* PostObject + policy用 uni.uploadFile 直传本地路径,不经 JS 读 ArrayBuffer调试基座读图失败时的主路径
*/
export async function ossPostObjectFromLocalFilePath({
bucket,
ossRegionId,
accessKeyId,
accessKeySecret,
securityToken,
objectKey,
filePath
}) {
const ak = normalizeAccessKeyPart(accessKeyId)
const sk = normalizeAccessKeyPart(accessKeySecret)
if (!ak || !sk) {
return Promise.reject(new Error('OSS 缺少 AccessKeyId 或 AccessKeySecret'))
}
const expiration = new Date(Date.now() + 55 * 60 * 1000).toISOString()
const conditions = [
['content-length-range', 0, 52428800],
{ bucket },
['eq', '$key', objectKey]
]
if (securityToken) {
conditions.push({ 'x-oss-security-token': securityToken })
}
const policyJson = JSON.stringify({ expiration, conditions })
const policyB64 = utf8StringToBase64(policyJson)
const signature = await hmacSha1Base64(policyB64, sk)
const host = ossHost(bucket, ossRegionId)
const url = `https://${host}/`
const formData = {
key: objectKey,
policy: policyB64,
OSSAccessKeyId: ak,
Signature: signature,
success_action_status: '200'
}
if (securityToken) {
formData['x-oss-security-token'] = securityToken
}
return new Promise((resolve, reject) => {
uni.uploadFile({
url,
filePath,
name: 'file',
formData,
timeout: 180000,
success: (res) => {
const sc = res.statusCode
if (sc >= 200 && sc < 300) {
resolve()
return
}
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data || '')
if (typeof body === 'string' && body.includes('SignatureDoesNotMatch')) {
reject(
new Error(
'OSS SignatureDoesNotMatchAccessKeySecret 与当前 OSSAccessKeyId 不匹配,或密钥含隐形字符/换行。请到 RAM 用户页「创建 AccessKey」重新复制 Secret 写入 config/segmentApi.js若曾泄露请删除旧密钥并轮换。'
)
)
return
}
reject(new Error(body || `OSS PostObject 失败 HTTP ${sc}`))
},
fail: (e) => {
const raw = e.errMsg || 'uploadFile 失败'
if (
looksLikeBareOssEndpointUrl(raw) ||
(/request:fail/i.test(raw) && /\.oss-[a-z0-9-]+\.aliyuncs\.com/i.test(raw))
) {
reject(
new Error(
humanizeIfBareOssUploadErr(raw)
)
)
return
}
reject(new Error(raw))
}
})
})
}
function uniRequestRaw(options) {
const timeout = options.timeout != null ? options.timeout : 120000
return new Promise((resolve, reject) => {
uni.request({
...options,
timeout,
success: (r) => {
if (r.statusCode >= 200 && r.statusCode < 300) {
resolve(r)
} else {
const msg =
(typeof r.data === 'string' ? r.data : '') ||
(r.data && r.data.Message) ||
`HTTP ${r.statusCode}`
reject(new Error(msg))
}
},
fail: (e) => reject(new Error(e.errMsg || '请求失败'))
})
})
}
/**
* PUT 上传后生成 OSS 签名 GET URL私有 Bucket 时分割接口需可访问该 URL
*/
export async function ossPutAndSignedGetUrl({
bucket,
ossRegionId,
accessKeyId,
accessKeySecret,
securityToken,
objectKey,
bodyBuffer,
contentType = 'image/jpeg'
}) {
const ak = normalizeAccessKeyPart(accessKeyId)
const sk = normalizeAccessKeyPart(accessKeySecret)
if (!ak || !sk) {
throw new Error('OSS 签名缺少 AccessKeyId 或 AccessKeySecret')
}
const host = ossHost(bucket, ossRegionId)
const path = ossObjectPath(objectKey)
const urlPut = `https://${host}/${path}`
const dateGmt = new Date().toUTCString()
/**
* uni.request 会带 Date若再发 x-oss-dateOSS 的 StringToSign 为Date 行 + 规范头里的 x-oss-date与错误 XML 一致),不能签成 Date 为空。
*/
const ossHeaderPairs = []
ossHeaderPairs.push(['x-oss-date', dateGmt])
if (securityToken) {
ossHeaderPairs.push(['x-oss-security-token', securityToken])
}
ossHeaderPairs.sort((a, b) => a[0].localeCompare(b[0]))
const canonHeaders = ossHeaderPairs.map(([k, v]) => `${k}:${v}\n`).join('')
const resource = `/${bucket}/${objectKey}`
const stringToSignPut = `PUT\n\n${contentType}\n${dateGmt}\n${canonHeaders}${resource}`
const sigPut = await hmacSha1Base64(stringToSignPut, sk)
const headersPut = {
Date: dateGmt,
'Content-Type': contentType,
'x-oss-date': dateGmt,
Authorization: `OSS ${ak}:${sigPut}`
}
if (securityToken) {
headersPut['x-oss-security-token'] = securityToken
}
await uniRequestRaw({
url: urlPut,
method: 'PUT',
header: headersPut,
data: bodyBuffer,
/** 大图上传Bitmap 兜底常为 PNG易慢超时后 fail 才会结束 loading */
timeout: 180000
})
return await ossPresignedObjectUrl({
bucket,
ossRegionId,
accessKeyId: ak,
accessKeySecret: sk,
securityToken,
objectKey,
expiresSec: 3600
})
}
/** IVPD 常见英文报错 → 中文说明(控制台需开通「智能视觉生产」等产品) */
function mapIvpdUserMessage(raw) {
const s = String(raw || '')
if (/please\s+open\s+service/i.test(s)) {
return (
'IVPD智能视觉生产服务尚未开通请用阿里云主账号或有权限的子账号登录控制台搜索「智能视觉生产」或「视觉智能开放平台」进入产品页点击「立即开通」后再试抠图。' +
(s.length <= 60 ? `(接口原文:${s}` : '')
)
}
return s
}
/**
* 智能视觉生产 IVPD「通用分割 / 抠图」SegmentImage。
* Endpointivpd.{region}.aliyuncs.comVersion2019-06-25参数 Url 为可访问的图片 HTTP(S) URL如 OSS 签名 GET
*/
export async function rpcIvpdSegmentImage({
imageUrl,
accessKeyId,
accessKeySecret,
securityToken,
regionId
}) {
const ak = normalizeAccessKeyPart(accessKeyId)
const sk = normalizeAccessKeyPart(accessKeySecret)
const u = String(imageUrl || '').trim()
if (!/^https?:\/\//i.test(u)) {
throw new Error('IVPD SegmentImage 需要可访问的 HTTP(S) 图片 URL一般为 OSS 私有桶签名 GET')
}
const params = {
Format: 'JSON',
Version: '2019-06-25',
AccessKeyId: ak,
SignatureMethod: 'HMAC-SHA1',
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
SignatureVersion: '1.0',
SignatureNonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
Action: 'SegmentImage',
RegionId: regionId,
Url: u
}
if (securityToken) {
params.SecurityToken = securityToken
}
const sortedKeys = Object.keys(params).sort()
const canonicalized = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join('&')
const stringToSign = `POST&${percentEncode('/')}&${percentEncode(canonicalized)}`
const signature = await hmacSha1Base64(stringToSign, `${sk}&`)
const body = `${canonicalized}&Signature=${percentEncode(signature)}`
const host = `ivpd.${regionId}.aliyuncs.com`
let res
try {
res = await uniRequestRaw({
url: `https://${host}/`,
method: 'POST',
header: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: body,
timeout: 120000
})
} catch (e) {
const em = e && e.message ? String(e.message) : ''
if (/please\s+open\s+service/i.test(em)) {
throw new Error(mapIvpdUserMessage(em))
}
try {
const j = JSON.parse(em)
const mm = j.Message || j.message || ''
if (/please\s+open\s+service/i.test(String(mm))) {
throw new Error(mapIvpdUserMessage(mm))
}
} catch (inner) {
if (inner && inner.message && /IVPD/.test(inner.message)) {
throw inner
}
}
throw e
}
let data = res.data
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch (e) {
throw new Error((data && data.slice(0, 240)) || 'IVPD 返回非 JSON')
}
}
const flat = data
const bizCode = flat.code ?? flat.Code
const bizMsg = flat.message ?? flat.Message
if (bizCode != null && String(bizCode) !== '0') {
throw new Error(mapIvpdUserMessage(bizMsg ? `${bizCode}: ${bizMsg}` : String(bizCode)))
}
const aliPopMsg = flat.Message || flat.Code
if (typeof aliPopMsg === 'string' && /please\s+open\s+service/i.test(aliPopMsg)) {
throw new Error(mapIvpdUserMessage(aliPopMsg))
}
const result = flat.result ?? flat.Result
const outUrl =
(result && (result.url || result.URL)) ||
flat.url ||
flat.URL ||
pickHttpImageUrl(flat)
if (outUrl && isLikelyObjectHttpUrl(outUrl)) {
return outUrl
}
throw new Error(
bizMsg
? mapIvpdUserMessage(bizMsg)
: `IVPD 未返回有效结果图 URL。摘录${JSON.stringify(flat).slice(0, 600)}`
)
}
/** 人像高清抠图imageseg SegmentHDBody自建代理 server/index.js 等可使用。 */
export async function rpcSegmentHDBody({
imageURL,
accessKeyId,
accessKeySecret,
securityToken,
regionId
}) {
const ak = normalizeAccessKeyPart(accessKeyId)
const sk = normalizeAccessKeyPart(accessKeySecret)
const params = {
Format: 'JSON',
Version: '2019-12-30',
AccessKeyId: ak,
SignatureMethod: 'HMAC-SHA1',
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
SignatureVersion: '1.0',
SignatureNonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
Action: 'SegmentHDBody',
ImageURL: imageURL
}
if (securityToken) {
params.SecurityToken = securityToken
}
const sortedKeys = Object.keys(params).sort()
const canonicalized = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join('&')
const stringToSign = `POST&${percentEncode('/')}&${percentEncode(canonicalized)}`
const signature = await hmacSha1Base64(stringToSign, `${sk}&`)
const body = `${canonicalized}&Signature=${percentEncode(signature)}`
const host = `imageseg.${regionId}.aliyuncs.com`
const res = await uniRequestRaw({
url: `https://${host}/`,
method: 'POST',
header: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: body,
timeout: 90000
})
let data = res.data
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch (e) {
throw new Error((data && data.slice(0, 240)) || '分割接口返回非 JSON')
}
}
let flat = data
if (flat && typeof flat.Data === 'string') {
try {
const inner = JSON.parse(flat.Data)
flat = { ...flat, ...inner }
} catch (_) {
/* ignore */
}
}
const outUrl =
flat?.ImageURL ||
flat?.imageURL ||
flat?.Data?.ImageURL ||
pickHttpImageUrl(flat)
if (!outUrl) {
const msg = (flat && (flat.Message || flat.Code)) || JSON.stringify(flat).slice(0, 200)
throw new Error(msg || '分割接口未返回结果图 URL')
}
return outUrl
}