/** * 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 }