321 lines
9.2 KiB
JavaScript
321 lines
9.2 KiB
JavaScript
import {
|
||
ALIYUN_ACCESS_KEY_ID,
|
||
ALIYUN_ACCESS_KEY_SECRET,
|
||
ALIYUN_SECURITY_TOKEN,
|
||
ALIYUN_STS_URL,
|
||
IMM_REGION,
|
||
OSS_BUCKET,
|
||
OSS_KEY_PREFIX,
|
||
OSS_REGION,
|
||
SEGMENT_API_BASE,
|
||
SEGMENT_API_TOKEN,
|
||
SEGMENT_TRANSPORT
|
||
} from './segmentApi.js'
|
||
import {
|
||
ossPostObjectFromLocalFilePath,
|
||
ossPresignedObjectUrl,
|
||
rpcIvpdSegmentImage,
|
||
looksLikeBareOssEndpointUrl
|
||
} from './aliyunPortraitUni.js'
|
||
|
||
function uniRequest(options) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.request({
|
||
...options,
|
||
success: (r) => {
|
||
if (r.statusCode >= 200 && r.statusCode < 300) {
|
||
resolve(r)
|
||
} else {
|
||
const msg =
|
||
(typeof r.data === 'object' && r.data && (r.data.Message || r.data.message)) ||
|
||
(typeof r.data === 'string' ? r.data : '') ||
|
||
`HTTP ${r.statusCode}`
|
||
reject(new Error(msg))
|
||
}
|
||
},
|
||
fail: (e) => reject(new Error(e.errMsg || '网络请求失败'))
|
||
})
|
||
})
|
||
}
|
||
|
||
/** 内层 Promise 长期无回调时强制失败(避免一直「正在识别」) */
|
||
function raceWithTimeout(promise, ms, errMsg) {
|
||
return new Promise((resolve, reject) => {
|
||
const t = setTimeout(() => reject(new Error(errMsg)), ms)
|
||
Promise.resolve(promise).then(
|
||
(v) => {
|
||
clearTimeout(t)
|
||
resolve(v)
|
||
},
|
||
(e) => {
|
||
clearTimeout(t)
|
||
reject(e)
|
||
}
|
||
)
|
||
})
|
||
}
|
||
|
||
function normalizeStsPayload(raw) {
|
||
if (!raw || typeof raw !== 'object') {
|
||
return null
|
||
}
|
||
if (raw.Credentials) {
|
||
const c = raw.Credentials
|
||
return {
|
||
accessKeyId: c.AccessKeyId,
|
||
accessKeySecret: c.AccessKeySecret,
|
||
securityToken: c.SecurityToken || undefined,
|
||
expiration: c.Expiration
|
||
}
|
||
}
|
||
if (raw.accessKeyId && raw.accessKeySecret) {
|
||
return {
|
||
accessKeyId: raw.accessKeyId,
|
||
accessKeySecret: raw.accessKeySecret,
|
||
securityToken: raw.securityToken || undefined,
|
||
expiration: raw.expiration
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function fetchStsCredentials() {
|
||
const url = (ALIYUN_STS_URL || '').trim()
|
||
if (!url) {
|
||
return null
|
||
}
|
||
const res = await uniRequest({ url, method: 'GET', timeout: 30000 })
|
||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||
const cred = normalizeStsPayload(data)
|
||
if (!cred || !cred.accessKeyId) {
|
||
throw new Error('STS 地址返回格式不正确(需 Credentials 或 accessKeyId/accessKeySecret)')
|
||
}
|
||
return cred
|
||
}
|
||
|
||
/** 开发直配 AK,或 STS 拉取 */
|
||
function getCredentialsFromConfig() {
|
||
const id = (ALIYUN_ACCESS_KEY_ID || '').trim()
|
||
const sec = (ALIYUN_ACCESS_KEY_SECRET || '').trim()
|
||
const token = (ALIYUN_SECURITY_TOKEN || '').trim()
|
||
if (id && sec) {
|
||
return {
|
||
accessKeyId: id,
|
||
accessKeySecret: sec,
|
||
securityToken: token || undefined
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function resolveCredentials() {
|
||
const fromFile = getCredentialsFromConfig()
|
||
if (fromFile) {
|
||
return fromFile
|
||
}
|
||
const url = (ALIYUN_STS_URL || '').trim()
|
||
if (!url) {
|
||
throw new Error('请配置 ALIYUN_ACCESS_KEY_ID/SECRET(开发)或 ALIYUN_STS_URL(生产)')
|
||
}
|
||
return fetchStsCredentials()
|
||
}
|
||
|
||
function canUseDirectAliyunClient() {
|
||
const bucket = (OSS_BUCKET || '').trim()
|
||
const hasAk =
|
||
!!(ALIYUN_ACCESS_KEY_ID || '').trim() && !!(ALIYUN_ACCESS_KEY_SECRET || '').trim()
|
||
const hasStsUrl = !!(ALIYUN_STS_URL || '').trim()
|
||
return !!(bucket && (hasAk || hasStsUrl))
|
||
}
|
||
|
||
/**
|
||
* @returns {'direct' | 'backend'}
|
||
*/
|
||
function resolveSegmentTransport() {
|
||
const raw = String(SEGMENT_TRANSPORT == null ? 'auto' : SEGMENT_TRANSPORT)
|
||
.trim()
|
||
.toLowerCase()
|
||
if (raw === 'backend' || raw === 'proxy' || raw === 'server') {
|
||
return 'backend'
|
||
}
|
||
if (raw === 'direct' || raw === 'aliyun' || raw === 'client' || raw === 'oss') {
|
||
if (!canUseDirectAliyunClient()) {
|
||
throw new Error(
|
||
'SEGMENT_TRANSPORT 为 direct/aliyun/client/oss 时需配置 OSS_BUCKET 以及(ALIYUN_ACCESS_KEY_ID+SECRET 或 ALIYUN_STS_URL)'
|
||
)
|
||
}
|
||
return 'direct'
|
||
}
|
||
return canUseDirectAliyunClient() ? 'direct' : 'backend'
|
||
}
|
||
|
||
/**
|
||
* 凭证 → OSS PostObject → 签名 GET URL → IVPD SegmentImage(Url)→ downloadFile
|
||
*/
|
||
async function segmentViaOssIvpd(filePath) {
|
||
const bucket = (OSS_BUCKET || '').trim()
|
||
if (!bucket) {
|
||
throw new Error('请在 config/segmentApi.js 配置 OSS_BUCKET')
|
||
}
|
||
|
||
const cred = await resolveCredentials()
|
||
|
||
const ext = /\.png$/i.test(String(filePath || '')) ? 'png' : 'jpg'
|
||
const base = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
||
const prefix = OSS_KEY_PREFIX.replace(/^\/+/, '')
|
||
const objectKeyIn = `${prefix}${base}.${ext}`
|
||
|
||
await ossPostObjectFromLocalFilePath({
|
||
bucket,
|
||
ossRegionId: OSS_REGION,
|
||
accessKeyId: cred.accessKeyId,
|
||
accessKeySecret: cred.accessKeySecret,
|
||
securityToken: cred.securityToken,
|
||
objectKey: objectKeyIn,
|
||
filePath
|
||
})
|
||
|
||
const imageUrlForSeg = await ossPresignedObjectUrl({
|
||
bucket,
|
||
ossRegionId: OSS_REGION,
|
||
accessKeyId: cred.accessKeyId,
|
||
accessKeySecret: cred.accessKeySecret,
|
||
securityToken: cred.securityToken,
|
||
objectKey: objectKeyIn,
|
||
expiresSec: 3600
|
||
})
|
||
|
||
const ivpdRegion = (IMM_REGION || '').trim() || String(OSS_REGION || '').replace(/^oss-/i, '')
|
||
|
||
const outUrl = await rpcIvpdSegmentImage({
|
||
imageUrl: imageUrlForSeg,
|
||
accessKeyId: cred.accessKeyId,
|
||
accessKeySecret: cred.accessKeySecret,
|
||
securityToken: cred.securityToken,
|
||
regionId: ivpdRegion
|
||
})
|
||
|
||
return raceWithTimeout(
|
||
new Promise((res, rej) => {
|
||
uni.downloadFile({
|
||
url: outUrl,
|
||
timeout: 120000,
|
||
success: (dr) => {
|
||
if (dr.statusCode !== 200 || !dr.tempFilePath) {
|
||
rej(new Error('下载分割结果失败'))
|
||
return
|
||
}
|
||
res({ kind: 'cutout', localPath: dr.tempFilePath })
|
||
},
|
||
fail: (e) => {
|
||
const raw = e.errMsg || '未知错误'
|
||
const needDomainHint =
|
||
looksLikeBareOssEndpointUrl(raw) ||
|
||
(/^https?:\/\//i.test(String(raw).trim()) && String(raw).includes('aliyuncs'))
|
||
const extra = needDomainHint
|
||
? '(若仅为 URL:请在 manifest → networkSecurity.domainWhiteList 中加入下载域名并重编译。)'
|
||
: ''
|
||
rej(new Error(`下载分割结果失败:${raw}${extra}`))
|
||
}
|
||
})
|
||
}),
|
||
125000,
|
||
'下载分割结果超时'
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 方案二:自建 Node 代理(server/index.js)
|
||
*/
|
||
function segmentViaProxy(filePath) {
|
||
const base = (SEGMENT_API_BASE || '').trim().replace(/\/+$/, '')
|
||
if (!base) {
|
||
return Promise.reject(
|
||
new Error(
|
||
'缺少 SEGMENT_API_BASE:HTTP/后端抠图需要填写根地址(POST {BASE}/segment)。若使用客户端直连阿里云,请配置 OSS_BUCKET 与 AK 或 STS,并将 SEGMENT_TRANSPORT 设为 auto 或 direct。'
|
||
)
|
||
)
|
||
}
|
||
const url = `${base}/segment`
|
||
|
||
return new Promise((resolve, reject) => {
|
||
uni.uploadFile({
|
||
url,
|
||
filePath,
|
||
name: 'image',
|
||
timeout: 120000,
|
||
header: SEGMENT_API_TOKEN ? { 'x-laser-token': SEGMENT_API_TOKEN } : {},
|
||
success: (res) => {
|
||
if (res.statusCode !== 200) {
|
||
reject(new Error(typeof res.data === 'string' ? res.data : `HTTP ${res.statusCode}`))
|
||
return
|
||
}
|
||
let body
|
||
try {
|
||
body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||
} catch (e) {
|
||
reject(new Error('代理返回非 JSON'))
|
||
return
|
||
}
|
||
if (!body || body.ok !== true || !body.imageUrl) {
|
||
reject(new Error((body && body.error) || '抠图失败'))
|
||
return
|
||
}
|
||
uni.downloadFile({
|
||
url: body.imageUrl,
|
||
timeout: 120000,
|
||
success: (dr) => {
|
||
if (dr.statusCode !== 200 || !dr.tempFilePath) {
|
||
reject(new Error('下载分割结果失败'))
|
||
return
|
||
}
|
||
resolve({ kind: 'cutout', localPath: dr.tempFilePath })
|
||
},
|
||
fail: (e) => reject(new Error(e.errMsg || 'downloadFile 失败'))
|
||
})
|
||
},
|
||
fail: (e) => reject(new Error(e.errMsg || 'uploadFile 失败'))
|
||
})
|
||
})
|
||
}
|
||
|
||
/** 防止某一步网络/原生无回调导致一直转圈 */
|
||
function withTimeout(promise, ms, message) {
|
||
let timer = null
|
||
return new Promise((resolve, reject) => {
|
||
timer = setTimeout(() => {
|
||
timer = null
|
||
reject(new Error(message))
|
||
}, ms)
|
||
promise.then(
|
||
(v) => {
|
||
if (timer != null) {
|
||
clearTimeout(timer)
|
||
}
|
||
resolve(v)
|
||
},
|
||
(e) => {
|
||
if (timer != null) {
|
||
clearTimeout(timer)
|
||
}
|
||
reject(e)
|
||
}
|
||
)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 上传本地图并获取抠图结果本地路径。
|
||
* 链路由 config/segmentApi.js 的 SEGMENT_TRANSPORT 决定:direct=客户端 OSS + IVPD SegmentImage;backend=HTTP 后端(契约同 server/index.js);auto=能直连则直连否则走后端。
|
||
*/
|
||
export function segmentPortraitToLocal(filePath) {
|
||
const transport = resolveSegmentTransport()
|
||
const inner =
|
||
transport === 'direct' ? segmentViaOssIvpd(filePath) : segmentViaProxy(filePath)
|
||
return withTimeout(
|
||
inner,
|
||
240000,
|
||
'智能抠图超时:请检查手机网络、OSS、IVPD 地域(IMM_REGION,如 cn-shanghai)、RAM 权限及 manifest 网络权限'
|
||
)
|
||
}
|