修复头像问题

This commit is contained in:
taiyi 2026-04-17 19:26:32 +08:00
parent dccaad830e
commit 3108a7b903
9 changed files with 300 additions and 25 deletions

2
.gitignore vendored
View File

@ -21,6 +21,7 @@ vendor/
packages/
backend/.venv
# 编译/打包产物
dist/
build/
@ -49,6 +50,7 @@ coverage/
# 上传文件
/uploads
/backend/uploads
/backend/static
# python环境

View File

@ -1,7 +1,10 @@
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.routers import api_router
@ -78,6 +81,10 @@ def create_app() -> FastAPI:
},
)
static_dir = Path(__file__).resolve().parent / "static"
static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(api_router, prefix=settings.api_v1_prefix)
return app

View File

@ -64,6 +64,8 @@ class UserUpdateRequest(BaseModel):
hobbies: list[str] | None = None
wechat_id: str | None = Field(default=None, max_length=PROFILE_LIMITS["wechat_id"])
phone: str | None = Field(default=None, max_length=PROFILE_LIMITS["phone"])
avatar_url: str | None = Field(default=None, max_length=500)
avatar_blur_url: str | None = Field(default=None, max_length=500)
value_answers: dict[str, Any] | None = None
prefer_age_min: int | None = None
prefer_age_max: int | None = None

View File

@ -11,10 +11,10 @@ from PIL import Image, ImageFilter
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.services.cos_service import build_cos_url
ALLOWED_IMAGE_TYPES = {"jpeg", "png"}
UPLOAD_ROOT = Path(__file__).resolve().parents[2] / "static" / "uploads"
def _validate_image(content: bytes) -> str:
@ -44,6 +44,21 @@ def _save_optimized_variants(content: bytes) -> tuple[str, str, str]:
return main_path, blur_path, timestamp
def _persist_avatar_files(user_id: int, timestamp: str, main_path: str, blur_path: str) -> tuple[str, str]:
user_dir = UPLOAD_ROOT / "avatars" / str(user_id)
user_dir.mkdir(parents=True, exist_ok=True)
avatar_name = f"{timestamp}.jpg"
blur_name = f"{timestamp}_blur.jpg"
avatar_path = user_dir / avatar_name
blur_target_path = user_dir / blur_name
avatar_path.write_bytes(Path(main_path).read_bytes())
blur_target_path.write_bytes(Path(blur_path).read_bytes())
return avatar_name, blur_name
async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile) -> dict:
content = await file.read()
if not content:
@ -52,14 +67,15 @@ async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile
raise ValueError("图片大小不能超过 5MB")
main_path, blur_path, timestamp = _save_optimized_variants(content)
base_key = f"avatars/{user.id}/{timestamp}"
try:
avatar_key = f"{base_key}.jpg"
blur_key = f"{base_key}_blur.jpg"
avatar_name, blur_name = _persist_avatar_files(user.id, timestamp, main_path, blur_path)
avatar_url = f"/static/uploads/avatars/{user.id}/{avatar_name}"
blur_url = f"/static/uploads/avatars/{user.id}/{blur_name}"
user.avatar_url = avatar_url
user.avatar_blur_url = blur_url
user.avatar_url = build_cos_url(avatar_key)
user.avatar_blur_url = build_cos_url(blur_key)
session.add(user)
await session.commit()
await session.refresh(user)
@ -68,8 +84,8 @@ async def upload_user_avatar(session: AsyncSession, user: User, file: UploadFile
"avatar_url": user.avatar_url,
"avatar_blur_url": user.avatar_blur_url,
"local_files": {
"avatar": Path(main_path).name,
"blur": Path(blur_path).name,
"avatar": avatar_name,
"blur": blur_name,
},
}
finally:

View File

@ -107,6 +107,8 @@ def _sanitize_update_value(field: str, value):
"phone",
"prefer_city",
"self_intro",
"avatar_url",
"avatar_blur_url",
} and isinstance(value, str):
value = value.strip()
return value or None

View File

@ -1,4 +1,21 @@
const request = require('../../utils/request')
const { getApiBaseUrl } = require('../../utils/constants')
function addCacheVersion(url, version) {
if (!url) {
return ''
}
const joiner = url.includes('?') ? '&' : '?'
return `${url}${joiner}v=${encodeURIComponent(version)}`
}
function getProfileCache() {
return wx.getStorageSync('profile_page_cache') || null
}
function setProfileCache(userInfo) {
wx.setStorageSync('profile_page_cache', userInfo)
}
const AUDIT_REQUIRED_FIELDS = ['personality_tags', 'hobbies', 'wechat_id', 'phone']
const PROFILE_LIMITS = {
@ -15,6 +32,16 @@ const PROFILE_LIMITS = {
listItems: 20
}
const MAX_AVATAR_SIZE = 5 * 1024 * 1024
function cloneProfileCache() {
return wx.getStorageSync('profile_page_cache') || null
}
function saveProfileCache(userInfo) {
wx.setStorageSync('profile_page_cache', userInfo)
}
Page({
data: {
form: {
@ -37,6 +64,8 @@ Page({
},
tagsText: '',
hobbiesText: '',
avatarUrl: '',
avatarVersion: '',
genderOptions: [
{ label: '男', value: 1 },
{ label: '女', value: 2 }
@ -96,9 +125,15 @@ Page({
personality_tags: data.personality_tags || [],
hobbies: data.hobbies || [],
wechat_id: data.wechat_id || '',
phone: data.phone || ''
phone: data.phone || '',
avatar_url: data.avatar_url || ''
}
this.setData({ form })
const avatarVersion = Date.now().toString()
this.setData({
form,
avatarUrl: addCacheVersion(form.avatar_url, avatarVersion),
avatarVersion
})
this.syncPickerTexts(form)
this.syncExtraTexts(form)
} catch (err) {
@ -277,6 +312,130 @@ Page({
return errors
},
chooseAvatar() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: (res) => {
const tempFilePath = res.tempFiles && res.tempFiles[0] && res.tempFiles[0].tempFilePath
if (tempFilePath) {
this.uploadAvatar(tempFilePath)
}
},
fail: (err) => {
if (err && !String(err.errMsg || '').includes('cancel')) {
wx.showToast({ title: '请选择头像图片', icon: 'none' })
}
}
})
},
uploadAvatar(tempFilePath) {
wx.getFileInfo({
filePath: tempFilePath,
success: (info) => {
if (info.size > MAX_AVATAR_SIZE) {
wx.showToast({ title: '头像不能超过 5MB', icon: 'none' })
return
}
wx.uploadFile({
url: `${getApiBaseUrl()}/upload/avatar`,
filePath: tempFilePath,
name: 'file',
header: {
Authorization: wx.getStorageSync('token') ? `Bearer ${wx.getStorageSync('token')}` : ''
},
success: (res) => {
try {
const body = JSON.parse(res.data)
if (body.code !== 0) {
throw new Error(body.message || '上传失败')
}
const rawAvatarUrl = body.data.avatar_url || ''
const avatarUrl = rawAvatarUrl.startsWith('http')
? rawAvatarUrl
: `${getApiBaseUrl()}${rawAvatarUrl}`
const avatarVersion = String(Date.now())
const form = { ...this.data.form, avatar_url: avatarUrl }
const cachedProfile = cloneProfileCache() || {}
const nextProfile = {
...cachedProfile,
...form,
avatar_url: avatarUrl,
avatar_blur_url: body.data.avatar_blur_url || cachedProfile.avatar_blur_url || '',
nicknameText: form.nickname || cachedProfile.nicknameText || '未设置昵称',
auditStatusText: cachedProfile.auditStatusText || '待审核'
}
saveProfileCache(nextProfile)
this.setData({ form, avatarUrl: addCacheVersion(avatarUrl, avatarVersion), avatarVersion })
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage && typeof prevPage.applyUserInfo === 'function') {
prevPage.applyUserInfo(nextProfile)
}
wx.showToast({ title: '头像已更新', icon: 'success' })
} catch (err) {
wx.showToast({ title: err.message || '上传失败', icon: 'none' })
}
},
fail: () => {
wx.showToast({ title: '上传失败,请重试', icon: 'none' })
}
})
},
fail: () => {
wx.showToast({ title: '无法读取图片大小', icon: 'none' })
}
})
},
validateProfilePayload(payload) {
const errors = []
const checkTextLength = (field, maxLength) => {
const value = payload[field]
if (typeof value === 'string' && value.length > maxLength) {
errors.push(`${this.getValidationLabel(field)}不能超过${maxLength}个字符`)
}
}
checkTextLength('nickname', PROFILE_LIMITS.nickname)
checkTextLength('real_name', PROFILE_LIMITS.real_name)
checkTextLength('city', PROFILE_LIMITS.city)
checkTextLength('job_industry', PROFILE_LIMITS.job_industry)
checkTextLength('job_company', PROFILE_LIMITS.job_company)
checkTextLength('prefer_city', PROFILE_LIMITS.prefer_city)
checkTextLength('self_intro', PROFILE_LIMITS.self_intro)
checkTextLength('wechat_id', PROFILE_LIMITS.wechat_id)
checkTextLength('phone', PROFILE_LIMITS.phone)
;['personality_tags', 'hobbies'].forEach((field) => {
const list = payload[field]
const label = this.getValidationLabel(field)
if (!Array.isArray(list)) {
errors.push(`${label}格式不正确`)
return
}
if (list.length > PROFILE_LIMITS.listItems) {
errors.push(`${label}最多${PROFILE_LIMITS.listItems}`)
}
list.forEach((item) => {
if (item.length > PROFILE_LIMITS.tagItem) {
errors.push(`${label}单项不能超过${PROFILE_LIMITS.tagItem}个字符`)
}
})
})
;['gender', 'birth_year', 'height', 'education', 'income_range'].forEach((field) => {
const value = payload[field]
if (value !== null && value !== undefined && !Number.isFinite(value)) {
errors.push(`${this.getValidationLabel(field)}格式不正确`)
}
})
return errors
},
async saveProfile() {
this.setData({ saving: true })
try {
@ -296,10 +455,27 @@ Page({
method: 'PUT',
data: payload
})
const nextForm = { ...this.data.form, ...payload }
const avatarVersion = this.data.avatarVersion || Date.now().toString()
const cachedProfile = cloneProfileCache() || {}
const nextProfile = {
...cachedProfile,
...nextForm,
avatar_url: nextForm.avatar_url || cachedProfile.avatar_url || '',
avatar_blur_url: nextForm.avatar_blur_url || cachedProfile.avatar_blur_url || ''
}
saveProfileCache(nextProfile)
this.setData({
form: { ...this.data.form, ...payload }
form: nextForm,
avatarUrl: addCacheVersion(nextForm.avatar_url || '', avatarVersion),
avatarVersion
})
this.syncPickerTexts(this.data.form)
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage && typeof prevPage.applyUserInfo === 'function') {
prevPage.applyUserInfo(nextProfile)
}
this.syncPickerTexts(nextForm)
wx.showToast({ title: '保存成功', icon: 'success' })
return true
} catch (err) {
@ -360,8 +536,13 @@ Page({
method: 'POST'
})
wx.showToast({ title: '已提交审核', icon: 'success' })
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage && typeof prevPage.loadProfile === 'function') {
prevPage.loadProfile()
}
setTimeout(() => {
wx.navigateTo({ url: '/pages/audit-status/audit-status' })
wx.navigateBack({ delta: 1 })
}, 300)
} catch (err) {
console.error('Submit audit failed:', err)

View File

@ -1,5 +1,11 @@
<view class="page form-page">
<view class="form-card">
<view class="avatar-section" bindtap="chooseAvatar">
<image wx:if="{{avatarUrl}}" class="avatar-image" src="{{avatarUrl}}" mode="aspectFill" />
<view wx:else class="avatar-image avatar-placeholder">点此上传</view>
<view class="avatar-hint">点击更换头像</view>
</view>
<view class="form-item">
<text class="label">昵称</text>
<input class="input" value="{{form.nickname}}" data-field="nickname" bindinput="handleInput" placeholder="请输入昵称" />

View File

@ -8,6 +8,35 @@
padding: 24rpx;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 0 32rpx;
}
.avatar-image {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: #f2f3f5;
display: flex;
align-items: center;
justify-content: center;
color: #999999;
font-size: 24rpx;
}
.avatar-placeholder {
border: 2rpx dashed #d9d9d9;
}
.avatar-hint {
margin-top: 16rpx;
color: #999999;
font-size: 24rpx;
}
.form-item {
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;

View File

@ -1,5 +1,13 @@
const request = require('../../utils/request')
function addCacheVersion(url, version) {
if (!url) {
return ''
}
const joiner = url.includes('?') ? '&' : '?'
return `${url}${joiner}v=${encodeURIComponent(version)}`
}
Page({
data: {
loading: false,
@ -13,25 +21,47 @@ Page({
},
onShow() {
this.loadProfile()
this.refreshFromCache()
this.loadProfile(true)
},
async loadProfile() {
this.setData({ loading: true })
async loadProfile(silent = false) {
if (!silent) {
this.setData({ loading: true })
}
try {
const userInfo = await request({ url: '/users/me' })
this.setData({
userInfo: {
...userInfo,
nicknameText: userInfo.nickname || '未设置昵称',
auditStatusText: this.data.auditTextMap[userInfo.audit_status] || '未知'
}
})
this.applyUserInfo(userInfo)
wx.setStorageSync('profile_page_cache', userInfo)
} catch (err) {
console.error('Load profile failed:', err)
this.setData({ userInfo: null })
if (!this.data.userInfo) {
this.setData({ userInfo: null })
}
} finally {
this.setData({ loading: false })
if (!silent) {
this.setData({ loading: false })
}
}
},
applyUserInfo(userInfo) {
const avatarVersion = Date.now().toString()
this.setData({
userInfo: {
...userInfo,
avatar_url: addCacheVersion(userInfo.avatar_url || '', avatarVersion),
avatar_blur_url: addCacheVersion(userInfo.avatar_blur_url || '', avatarVersion),
nicknameText: userInfo.nickname || '未设置昵称',
auditStatusText: this.data.auditTextMap[userInfo.audit_status] || '未知'
}
})
},
refreshFromCache() {
const cached = wx.getStorageSync('profile_page_cache')
if (cached) {
this.applyUserInfo(cached)
}
},