修复头像问题
This commit is contained in:
parent
dccaad830e
commit
3108a7b903
2
.gitignore
vendored
2
.gitignore
vendored
@ -21,6 +21,7 @@ vendor/
|
||||
packages/
|
||||
backend/.venv
|
||||
|
||||
|
||||
# 编译/打包产物
|
||||
dist/
|
||||
build/
|
||||
@ -49,6 +50,7 @@ coverage/
|
||||
# 上传文件
|
||||
/uploads
|
||||
/backend/uploads
|
||||
/backend/static
|
||||
|
||||
|
||||
# python环境
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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="请输入昵称" />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user