diff --git a/src/mixins/sharing-page.js b/src/mixins/sharing-page.js
index 6ce8561..f6423ea 100644
--- a/src/mixins/sharing-page.js
+++ b/src/mixins/sharing-page.js
@@ -31,7 +31,10 @@ export default function sharingPage(kind) {
if(this._sharingDisposed)return
const profile=displayProfile(data)
this.setPosterProgress(15,kind==='personal'?'正在选择适合的生活照':'正在准备专属邀请')
- if(kind==='personal')profile.photo=await selectSharingPhoto(profile.photos)
+ if(kind==='personal'){
+ const previousCache=await readSharingCache(profile.id,kind)
+ profile.photo=await selectSharingPhoto(profile.photos,{random:force,previous:this.profile.photo||previousCache?.selectedPhoto,preferred:previousCache?.selectedPhoto})
+ }
const cache=force?null:await readSharingCache(profile.id,kind,profile)
if(this._sharingDisposed)return
this.profile=profile;this.profileReady=true
diff --git a/src/pages/users/shareCard.wpy b/src/pages/users/shareCard.wpy
index becd0dd..7d09ebd 100644
--- a/src/pages/users/shareCard.wpy
+++ b/src/pages/users/shareCard.wpy
@@ -14,7 +14,7 @@
{{profileLoading ? '正在读取个人资料' : posterStage}}
{{posterError || profileError}}
{{cacheNote}} · 点击查看大图
- 资料变更后,点击“更新海报”即可重新生成。
+ 点击“更新海报”,随机换一张合适的生活照。
diff --git a/src/utils/sharing-cache.js b/src/utils/sharing-cache.js
index 1d1c006..597ab88 100644
--- a/src/utils/sharing-cache.js
+++ b/src/utils/sharing-cache.js
@@ -43,7 +43,7 @@ export async function writeSharingCache(profile,kind,assets) {
await boundedCall((resolve,reject)=>wx.getFileSystemManager().copyFile({srcPath:assets[name],destPath:paths[name],success:resolve,fail:reject}), '图片缓存失败', 3000)
}
const index=readIndex(),previous=index[key]
- const record={...paths,version:VERSION,fingerprint:sharingFingerprint(profile,kind),auth:digest(String(wx.getStorageSync('token')||'')),savedAt:Date.now()}
+ const record={...paths,selectedPhoto:profile.photo||'',version:VERSION,fingerprint:sharingFingerprint(profile,kind),auth:digest(String(wx.getStorageSync('token')||'')),savedAt:Date.now()}
index[key]=record
const expired=Object.keys(index).filter(k=>k!==key).sort((a,b)=>(index[b].savedAt||0)-(index[a].savedAt||0)).slice(3)
const discarded=expired.map(k=>index[k]);expired.forEach(k=>delete index[k])
diff --git a/src/utils/sharing-photo.js b/src/utils/sharing-photo.js
index 50e4c1d..48d0b16 100644
--- a/src/utils/sharing-photo.js
+++ b/src/utils/sharing-photo.js
@@ -10,7 +10,7 @@ export function photoScore(width,height) {
const resolution=Math.min(1,Math.min(width/686,height/470))
return crop*0.8+resolution*0.2
}
-export async function selectSharingPhoto(photos) {
+export async function selectSharingPhoto(photos,options={}) {
const candidates=[...new Set((photos||[]).filter(url=>typeof url==='string'&&url.trim()))].slice(0,9)
const checked=await Promise.all(candidates.map(async url=>{
try{
@@ -18,5 +18,13 @@ export async function selectSharingPhoto(photos) {
return {url,score:photoScore(info.width,info.height)}
}catch(_){return {url,score:-1}}
}))
- return checked.filter(item=>item.score>=0).sort((a,b)=>b.score-a.score)[0]?.url||''
+ const eligible=checked.filter(item=>item.score>=0)
+ if(!eligible.length)return ''
+ if(options.random){
+ const alternatives=eligible.filter(item=>item.url!==options.previous)
+ const pool=alternatives.length?alternatives:eligible
+ return pool[Math.floor(Math.random()*pool.length)].url
+ }
+ if(options.preferred&&eligible.some(item=>item.url===options.preferred))return options.preferred
+ return eligible.sort((a,b)=>b.score-a.score)[0].url
}
diff --git a/src/utils/sharing-runtime.js b/src/utils/sharing-runtime.js
index 5c2c8c1..976bba9 100644
--- a/src/utils/sharing-runtime.js
+++ b/src/utils/sharing-runtime.js
@@ -26,6 +26,17 @@ export function sharingRequest(url, method = 'GET', data = {}) {
const body = response.data
if (response.statusCode === 200 && body && body.code === 0 && body.data != null) return resolve(body.data)
if (response.statusCode === 401 || (body && (body.code === 2 || body.code === 3))) return reject(new Error('登录状态已失效,请重新登录后再试'))
+ if(url.endsWith('/sharing/code')){
+ if(response.statusCode!==200)return reject(new Error(`分享码接口异常(HTTP ${Number(response.statusCode)||0}),请联系客服`))
+ const messages={qr_token_refresh:'分享码凭证更新失败,请稍后重试',qr_upstream_unavailable:'分享码服务暂不可用,请稍后重试',qr_image_read:'分享码图片读取失败,请重试',qr_image_format:'分享码图片格式异常,请联系客服',qr_wechat_rejected:'微信未能生成分享码,请联系客服'}
+ const message=messages[body&&body.error_key]
+ const code=Number(body&&body.upstream_code)
+ if(message)return reject(new Error(message+(Number.isSafeInteger(code)&&code!==0?`(微信码 ${code})`:'')))
+ // Old servers do not have error_key. Only expose known safe messages.
+ const known=['生成分享码失败,请稍后重试','生成分享码失败','分享码格式无效','分享类型无效']
+ if(body&&known.includes(body.message))return reject(new Error(body.message))
+ return reject(new Error('分享码获取失败,请联系客服核查接口版本'))
+ }
reject(new Error('暂时无法获取分享信息,请稍后重试'))
},
fail() { reject(new Error('网络连接失败,请检查网络后重试')) }
diff --git a/tools/sharing/VALIDATION-20261102.md b/tools/sharing/VALIDATION-20261102.md
index 2d11058..30b86de 100644
--- a/tools/sharing/VALIDATION-20261102.md
+++ b/tools/sharing/VALIDATION-20261102.md
@@ -75,3 +75,12 @@ Node v24.19.0 六组回归均通过:check-image-path、check、check-cache、c
开发者工具启动过程:自动化 SDK 在 Node24 启动 bat 失败,改用官方 CLI;游客 AppID 不被当前工具接受,改用仓库已有 AppID,仍仅加载独立禁网探针。探针分包注册失败后改成普通页面入口完成验证。测试后关闭探针项目。未上传、未部署、未更新 main 验收包;改动保留当前 feature,包含此前邀请列表详情入口。
2026-11-02 用户确认编译本轮修复进行验收。按既有授权合并本地 main 后编译,新制品和精确提交记录见 releases/20261102-poster-fix-main。未执行上传或部署。
+
+## 2026-11-03 手动更新随机换生活照
+
+分支 feature/card-photo-refresh。个人卡片点击“更新海报”或失败后的“重新生成”,重新读取本人生活照并筛选既有尺寸/比例条件,在合格候选中随机选择;多张时排除上一张,单张继续使用,无合格照片返回品牌默认背景。首次进入仍按适配度优选;将 selectedPhoto 写入本地缓存,正常重进保持上次随机照片,照片删除或不再合格时重新选择。邀请海报行为不变。
+
+Node v24.19.0:六组分享回归退出0。check-photo 增加随机排除当前、单张、无照片、优先缓存照片、重进不重新导出、照片删除后替换;额外执行生成失败后点击重试换图检查,退出0。git diff --check通过,后端没有本次改动,未上传发布。
+Node v16.20.2 在 .release-build/invitation-card-candidate 执行 tools/build/build.cjs,退出0,89模板编译完成。改动仍在功能分支,未更新 main 验收包。
+
+2026-11-03 用户确认后端修复生效但线上换图未变化。核实上一交付前端 main f82dab2 仍固定优选,随机换图代码尚在功能分支。本轮补齐前端交付,合并后从 main 编译;多张合格照片排除当前项,缓存保留新选择。
diff --git a/tools/sharing/check-code-errors.cjs b/tools/sharing/check-code-errors.cjs
new file mode 100644
index 0000000..fb58674
--- /dev/null
+++ b/tools/sharing/check-code-errors.cjs
@@ -0,0 +1,12 @@
+const assert=require('assert'),{createHarness}=require('./harness.cjs')
+;(async()=>{
+ const h=createHarness(),{sharingRequest}=h.load('utils/sharing-runtime'),url='https://synthetic.invalid/user/sharing/code'
+ for(const [response,pattern] of [
+ [{statusCode:404,data:{}},/HTTP 404/],
+ [{statusCode:200,data:{code:1,error_key:'qr_wechat_rejected',upstream_code:41030,message:'secret'}},/微信码 41030/],
+ [{statusCode:200,data:{code:1,error_key:'qr_token_refresh'}},/凭证更新失败/],
+ [{statusCode:200,data:{code:1,message:'生成分享码失败,请稍后重试'}},/生成分享码失败/],
+ [{statusCode:200,data:{code:1,message:'SYNTHETIC_PRIVATE_SECRET'}},/核查接口版本/]
+ ]){h.flags.response=response;await assert.rejects(sharingRequest(url,'POST',{kind:'invite'}),e=>pattern.test(e.message)&&!e.message.includes('secret')&&!e.message.includes('PRIVATE'))}
+ console.log('PASS: QR HTTP/WeChat/credential/legacy failures distinguishable without leaking raw responses.')
+})().catch(e=>{console.error(e.stack);process.exitCode=1})
diff --git a/tools/sharing/check-photo.cjs b/tools/sharing/check-photo.cjs
index fc6dffe..e556a75 100644
--- a/tools/sharing/check-photo.cjs
+++ b/tools/sharing/check-photo.cjs
@@ -10,6 +10,18 @@ const assert=require('assert'),{createHarness}=require('./harness.cjs')
assert.equal(await selectSharingPhoto(['portrait','bad','small','fit','panorama']),'fit')
assert.equal(await selectSharingPhoto(['small','bad','panorama']),'')
assert.equal(await selectSharingPhoto([]),'')
+ assert.equal(await selectSharingPhoto(['portrait','fit','small','bad'],{random:true,previous:'fit'}),'portrait')
+ assert.equal(await selectSharingPhoto(['fit','fit'],{random:true,previous:'fit'}),'fit')
+ assert.equal(await selectSharingPhoto(['bad','small'],{random:true}),'')
+ assert.equal(await selectSharingPhoto(['portrait','fit'],{preferred:'portrait'}),'portrait')
+ const refresh=createHarness();refresh.state.profile.photos=['first','second']
+ const refreshed=refresh.page('shareCard');refreshed.hook('onReady');await refreshed.startSharing()
+ const initial=refreshed.profile.photo;await refreshed.refreshPoster()
+ assert.notEqual(refreshed.profile.photo,initial);assert(refreshed.poster)
+ const chosen=refreshed.profile.photo,poster=refreshed.poster,exportsBefore=refresh.state.exportID
+ const revisit=refresh.page('shareCard');revisit.hook('onReady');await revisit.startSharing()
+ assert.equal(revisit.profile.photo,chosen);assert.equal(revisit.poster,poster);assert.equal(refresh.state.exportID,exportsBefore)
+ refresh.state.profile.photos=[initial];await revisit.loadSharingProfile();assert.equal(revisit.profile.photo,initial)
const runtime=h.load('utils/sharing-runtime')
assert.equal(runtime.displayProfile({id:42,photo:'avatar-only'}).photo,'')
const empty=createHarness();empty.state.profile.photos=[]