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 网络权限' ) }