- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0 - 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md): - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast) - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto) - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天) - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入 - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置 - 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖) 未验证项(打标签 ≠ 生产发布): - 后端编译 / 单测未在本环境执行(Maven 慢、Docker DNS 受限) - 管理端 登录→刷新→登出 端到端、小程序真机走查待人工验收 - 生产部署需注入 WECHAT_APP_ID / WECHAT_APP_SECRET、VERIFICATION_API_URL / VERIFICATION_API_KEY 与小程序生产域名
214 lines
6.7 KiB
JavaScript
214 lines
6.7 KiB
JavaScript
/**
|
||
* 头像与照片页 U08(Phase 04 P04-T23)
|
||
*
|
||
* 重构要点(遵循 AGENTS.md 不变量 + Phase 04 验收条件):
|
||
* - 数据:profileService.getPhotos(每次从服务端获取,不在本地缓存图片 URL —— 验收"图片URL非永久公开")
|
||
* - 上传:profileService.requestUploadToken + wx.uploadFile(EXIF 由服务端清理,前端不处理)
|
||
* - 删除:profileService.deletePhoto
|
||
* - 主头像:profileService.setMainPhoto(第一张或用户指定)
|
||
* - 数量限制:BUSINESS_LIMITS.MAX_PROFILE_PHOTOS(第 12 条,不硬编码)
|
||
* - 敏感数据:本页不涉及身份证/手机号(第 8 条)
|
||
* - 下一步条件:至少 1 张主头像
|
||
*/
|
||
const { appStore } = require('../../stores');
|
||
const { profileService } = require('../../services');
|
||
const { ROUTE, BUSINESS_LIMITS } = require('../../constants');
|
||
|
||
Page({
|
||
data: {
|
||
statusBarHeight: 20,
|
||
navBarHeight: 44,
|
||
loading: false,
|
||
saving: false,
|
||
avatarPhotos: [],
|
||
lifestylePhotos: [],
|
||
maxLifestyle: BUSINESS_LIMITS.MAX_PROFILE_PHOTOS - 1,
|
||
// 页面说明
|
||
guidelines: [
|
||
'请使用本人近期真实照片',
|
||
'禁止网图、二维码、广告、联系方式图片',
|
||
'建议真人生活照,避免过度美颜',
|
||
],
|
||
},
|
||
|
||
onLoad() {
|
||
this._initSystemInfo();
|
||
this._applyPhotoLimit();
|
||
this.loadPhotos();
|
||
},
|
||
|
||
onShow() {
|
||
// 服务端配置(appStore.businessConfig)可能在冷启动后才拉取完成,onShow 再取一次
|
||
this._applyPhotoLimit();
|
||
},
|
||
|
||
/**
|
||
* 照片上限读运行时配置(服务端下发,经 appStore.businessConfig 覆盖本地默认),
|
||
* 前端不写死 6(PROFILE-001)。生活照槽位 = 总上限 - 1(头像占 1 张)。
|
||
*/
|
||
_applyPhotoLimit() {
|
||
const maxTotal = (appStore.state.businessConfig && appStore.state.businessConfig.MAX_PROFILE_PHOTOS)
|
||
|| BUSINESS_LIMITS.MAX_PROFILE_PHOTOS;
|
||
this.setData({ maxLifestyle: Math.max(0, maxTotal - 1) });
|
||
},
|
||
|
||
_initSystemInfo() {
|
||
try {
|
||
const sysInfo = wx.getSystemInfoSync();
|
||
this.setData({
|
||
statusBarHeight: sysInfo.statusBarHeight || 20,
|
||
navBarHeight: 44,
|
||
});
|
||
} catch (e) { /* 使用默认值 */ }
|
||
},
|
||
|
||
async loadPhotos() {
|
||
this.setData({ loading: true });
|
||
try {
|
||
const result = await profileService.getPhotos();
|
||
const photos = (result && result.photos) || [];
|
||
const avatarPhotos = photos.filter((p) => p.photoType === 'avatar');
|
||
const lifestylePhotos = photos.filter((p) => p.photoType === 'lifestyle');
|
||
this.setData({ avatarPhotos, lifestylePhotos, loading: false });
|
||
} catch (err) {
|
||
this.setData({ loading: false });
|
||
appStore.toastError(err, '加载照片失败');
|
||
}
|
||
},
|
||
|
||
// ============ 上传 ============
|
||
/** 主头像区添加 */
|
||
onAddAvatar() {
|
||
this._chooseAndUpload('avatar');
|
||
},
|
||
|
||
/** 生活照区添加 */
|
||
onAddLifestyle() {
|
||
const remaining = this.data.maxLifestyle - this.data.lifestylePhotos.length;
|
||
if (remaining <= 0) {
|
||
appStore.toast(`最多上传 ${this.data.maxLifestyle + 1} 张照片`);
|
||
return;
|
||
}
|
||
this._chooseAndUpload('lifestyle');
|
||
},
|
||
|
||
_chooseAndUpload(photoType) {
|
||
wx.chooseMedia({
|
||
count: 1,
|
||
mediaType: ['image'],
|
||
sourceType: ['album', 'camera'],
|
||
sizeType: ['compressed'],
|
||
success: (res) => {
|
||
const tempFile = res.tempFiles[0];
|
||
if (!tempFile) return;
|
||
this._uploadPhoto(tempFile.tempFilePath, photoType);
|
||
},
|
||
fail: () => { /* 用户取消 */ },
|
||
});
|
||
},
|
||
|
||
async _uploadPhoto(filePath, photoType) {
|
||
appStore.showLoading('上传中…');
|
||
try {
|
||
// 1. 获取上传凭证(EXIF 由服务端清理);带上 photoType 使 fileKey 路径与类型一致
|
||
const fileName = `photo_${Date.now()}.jpg`;
|
||
const tokenRes = await profileService.requestUploadToken({ fileName, contentType: 'image/jpeg', photoType });
|
||
|
||
// 2. 上传到对象存储
|
||
await new Promise((resolve, reject) => {
|
||
wx.uploadFile({
|
||
url: tokenRes.uploadUrl,
|
||
filePath,
|
||
name: 'file',
|
||
formData: { 'Content-Type': 'image/jpeg' },
|
||
success: (res) => {
|
||
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res);
|
||
else reject(new Error('上传失败'));
|
||
},
|
||
fail: reject,
|
||
});
|
||
});
|
||
|
||
// 3. 直传成功后必须调 confirm 落库,否则对象成孤儿、照片不展示(修复"照片直传不落库")
|
||
const confirmRes = await profileService.confirmPhoto({ fileKey: tokenRes.fileKey, photoType });
|
||
|
||
// 4. 若上传的是头像区,或当前无主头像,则设为主头像(用真实 photoId)
|
||
if (photoType === 'avatar' || this.data.avatarPhotos.length === 0) {
|
||
await profileService.setMainPhoto(confirmRes.photoId);
|
||
}
|
||
|
||
appStore.hideLoading();
|
||
appStore.toastSuccess('上传成功');
|
||
this.loadPhotos();
|
||
} catch (err) {
|
||
appStore.hideLoading();
|
||
appStore.toastError(err, '上传失败');
|
||
}
|
||
},
|
||
|
||
// ============ 删除 ============
|
||
async onRemoveAvatar(e) {
|
||
const { index } = e.detail;
|
||
const photo = this.data.avatarPhotos[index];
|
||
if (!photo) return;
|
||
this._removePhoto(photo.photoId);
|
||
},
|
||
|
||
async onRemoveLifestyle(e) {
|
||
const { index } = e.detail;
|
||
const photo = this.data.lifestylePhotos[index];
|
||
if (!photo) return;
|
||
this._removePhoto(photo.photoId);
|
||
},
|
||
|
||
async _removePhoto(photoId) {
|
||
const confirm = await appStore.confirm('确定删除这张照片?', '删除后不可恢复');
|
||
if (!confirm) return;
|
||
appStore.showLoading('删除中…');
|
||
try {
|
||
await profileService.deletePhoto(photoId);
|
||
appStore.hideLoading();
|
||
appStore.toastSuccess('已删除');
|
||
this.loadPhotos();
|
||
} catch (err) {
|
||
appStore.hideLoading();
|
||
appStore.toastError(err, '删除失败');
|
||
}
|
||
},
|
||
|
||
// ============ 预览 ============
|
||
onPreviewAvatar(e) {
|
||
const { index } = e.detail;
|
||
const photo = this.data.avatarPhotos[index];
|
||
if (photo) this._preview(photo.url);
|
||
},
|
||
|
||
onPreviewLifestyle(e) {
|
||
const { index } = e.detail;
|
||
const photo = this.data.lifestylePhotos[index];
|
||
if (photo) this._preview(photo.url);
|
||
},
|
||
|
||
_preview(currentUrl) {
|
||
const urls = [
|
||
...this.data.avatarPhotos.map((p) => p.url),
|
||
...this.data.lifestylePhotos.map((p) => p.url),
|
||
].filter(Boolean);
|
||
if (urls.length === 0) return;
|
||
wx.previewImage({ urls, current: currentUrl });
|
||
},
|
||
|
||
// ============ 导航 ============
|
||
onBack() {
|
||
wx.navigateBack();
|
||
},
|
||
|
||
onNext() {
|
||
if (this.data.avatarPhotos.length === 0) {
|
||
appStore.toast('请至少上传 1 张主头像');
|
||
return;
|
||
}
|
||
wx.navigateTo({ url: ROUTE.ONBOARDING.PROFILE_INTERESTS });
|
||
},
|
||
});
|