558 lines
17 KiB
JavaScript
558 lines
17 KiB
JavaScript
/**
|
||
* uni-app App 端可用:OSS Post/Put + 签名 GET URL;直连抠图默认 IVPD SegmentImage,另有 imageseg SegmentHDBody(POP,不依赖 ali-oss / Node http)
|
||
* 签名使用 Web Crypto(HMAC-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• IVPD:https://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 SignatureDoesNotMatch:AccessKeySecret 与当前 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-date,OSS 的 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。
|
||
* Endpoint:ivpd.{region}.aliyuncs.com,Version:2019-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
|
||
}
|