# 小程序问题修复方案 > 日期:2026-06-18 > 基于代码分析,针对 5 个问题逐一给出根因分析和修复方案 --- ## 问题一:150人短时间同时登录操作是否可以支持 ### 现状分析 | 组件 | 当前配置 | 问题 | |------|----------|------| | **uvicorn** | 单进程启动,无 worker 数配置 | 只有一个进程处理请求,无法利用多核 | | **MySQL 连接池** | `create_async_engine()` 无参数,SQLAlchemy 默认 `pool_size=5, max_overflow=10` | 最多 15 个并发数据库连接,150 人同时操作会排队 | | **Redis** | 单例连接,无连接池配置 | 高并发下 Redis 操作可能阻塞 | | **登录限流** | 10次/60秒/IP | 同一 IP 下多人登录会被限流(公司 WiFi 场景) | | **报名接口** | 无限流、无并发控制 | 150人同时报名可能触发 MySQL 死锁 | ### 结论:当前配置 **无法可靠支持** 150 人短时间同时操作 ### 修复方案 #### 1.1 uvicorn 多 worker 启动(必须) ```bash # 启动命令改为: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 # workers = CPU 核心数 * 2 + 1,4核机器建议 4-8 个 worker ``` #### 1.2 扩大数据库连接池(必须) **文件:`backend/app/database.py`** ```python # 当前: engine = create_async_engine(settings.database_url, echo=settings.app_debug, future=True) # 改为: engine = create_async_engine( settings.database_url, echo=settings.app_debug, future=True, pool_size=20, # 默认 5 → 20 max_overflow=30, # 默认 10 → 30 pool_recycle=3600, # 1小时回收空闲连接,防止 MySQL 超时断开 pool_pre_ping=True, # 使用前检测连接是否存活 ) ``` #### 1.3 报名接口加乐观锁 / 唯一索引保护(必须) **文件:`backend/app/services/activity_service.py`** `register_activity()` 中的 `count + check + insert` 不是原子操作,150 人并发报名可能导致超额。 ```python # 方案 A:利用数据库唯一索引(推荐,最简单) # registrations 表已有 (user_id, activity_id) 唯一索引,防重复报名没问题 # 但名额检查仍需改进: # 在 _count_registered_by_gender 中加 SELECT FOR UPDATE(悲观锁) async def _count_registered_by_gender(session, activity_id, gender): stmt = ( select(func.count()) .select_from(Registration) .join(User, User.id == Registration.user_id) .where( Registration.activity_id == activity_id, Registration.status.in_([1, 2, 4]), User.gender == gender, ) .with_for_update() # 加行锁,防止并发超额 ) result = await session.execute(stmt) return result.scalar() or 0 ``` ```python # 方案 B(更优):用 Redis 原子计数器做名额预检 # 报名前先 Redis DECR,成功才写数据库,失败回滚 # 适合极高并发场景,实现稍复杂 ``` #### 1.4 调整登录限流策略(建议) **文件:`backend/app/routers/auth.py`** 当前限流是 per IP 的,公司 WiFi 下 150 人共用一个 IP 会被限流。 ```python # 建议改为 per openid 限流,或放宽 IP 限流 # 方案:改为 60次/60秒/IP,或改为 per openid 5次/60秒 ``` #### 1.5 Nginx 反向代理(生产必须) 当前无 Nginx,uvicorn 直接对外服务。生产环境应加 Nginx: - 处理静态文件(减轻 uvicorn 压力) - 请求缓冲和超时控制 - 限流和 DDoS 防护 --- ## 问题二:点击报名一直显示加载中,无法报名成功 ### 根因分析 **文件:`miniprogram/pages/activity-detail/activity-detail.js:192-227`** 报名流程:`handleRegister()` → `POST /activities/{id}/bind` → 等待响应 "一直加载中" 说明请求没有返回(不是报错,报错会走 catch 分支)。可能原因: | 可能原因 | 概率 | 说明 | |----------|------|------| | **请求超时但未正确处理** | 高 | `request.js` 默认超时 10秒,但 `loadDetail()` 在 `bind` 成功后还要再请求一次活动详情,两个请求串联可能超时 | | **后端死锁/长时间阻塞** | 高 | `register_activity()` 中 `count + check + insert` 在高并发下可能死锁 | | **session 未正确 commit** | 中 | 如果 `session.commit()` 抛异常但被吞掉,请求会 hang | | **微信网络问题** | 低 | 弱网环境下请求可能丢失 | ### 修复方案 #### 2.1 前端:增加超时控制和重试机制(必须) **文件:`miniprogram/pages/activity-detail/activity-detail.js`** ```javascript async handleRegister() { if (!this.data.activity) return if (this.data.activity.is_bound || this.data.activity.is_registered) { wx.showToast({ title: '你已绑定该活动', icon: 'none' }) return } // 防重复点击 if (this.data.submitting) return this.setData({ submitting: true }) const timeoutId = setTimeout(() => { // 15秒后如果还在 loading,强制结束 if (this.data.submitting) { this.setData({ submitting: false }) wx.showToast({ title: '网络超时,请重试', icon: 'none' }) } }, 15000) try { await request({ url: `/activities/${this.data.id}/bind`, method: 'POST', timeout: 15000 // 单独设置更长超时 }) wx.showToast({ title: '报名成功', icon: 'success' }) await this.loadDetail() } catch (err) { console.error('Bind activity failed:', err) if (err && err.message && err.message.includes('资料')) { wx.setStorageSync('pending_activity_bind', { activity_id: this.data.id, share_token: this.data.shareToken || '' }) wx.showModal({ title: '请先完善资料', content: err.message, success: (res) => { if (res.confirm) { wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) } } }) return } wx.showToast({ title: err.message || '报名失败,请重试', icon: 'none' }) } finally { clearTimeout(timeoutId) this.setData({ submitting: false }) } } ``` #### 2.2 前端:bind 成功后不再二次请求 loadDetail(建议优化) 当前流程:bind → 成功 → `loadDetail()` 再请求一次 `/activities/{id}`。 在弱网下这意味着两个串行请求,耗时翻倍。 ```javascript // 优化:bind 成功后直接更新本地状态,不重新请求 await request({ url: `/activities/${this.data.id}/bind`, method: 'POST' }) wx.showToast({ title: '报名成功', icon: 'success' }) // 直接更新本地状态,避免二次请求 this.setData({ 'activity.is_bound': true, 'activity.is_registered': true, 'activity.actionText': '已报名' }) // 可选:后台静默刷新 this.loadDetail() ``` #### 2.3 后端:排查数据库死锁(必须) **文件:`backend/app/services/activity_service.py:233-267`** ```python # 当前代码在高并发下的问题: # 1. count(*) 不加锁 → 两个请求同时读到相同 count → 都认为有名额 → 都插入 → 超额 # 2. 如果 MySQL 的 unique 约束冲突,会抛 IntegrityError,但当前没捕获这个异常 # 修复:用 try/except 捕获 IntegrityError from sqlalchemy.exc import IntegrityError async def register_activity(session, activity, current_user): # ... 前面的检查不变 ... try: registration = Registration( user_id=current_user.id, activity_id=activity.id, status=1 ) session.add(registration) await session.commit() await session.refresh(registration) return registration except IntegrityError: # 并发场景下,另一个请求已经插入了,这里返回已有的 await session.rollback() existing = await get_registration(session, current_user.id, activity.id) if existing: return existing raise ``` #### 2.4 后端:添加请求超时日志(排查用) 在 `register` 和 `bind` 路由中加日志,记录请求耗时: ```python import time import logging logger = logging.getLogger(__name__) @router.post("/{activity_id}/bind") async def bind_activity(...): start = time.monotonic() try: # ... 原有逻辑 ... elapsed = time.monotonic() - start if elapsed > 5: logger.warning(f"Slow bind: activity={activity_id}, user={current_user_id}, elapsed={elapsed:.2f}s") return result except Exception: logger.exception(f"Bind failed: activity={activity_id}, user={current_user_id}") raise ``` --- ## 问题三:转发小程序活动到微信后,分享卡片上看不到图片 ### 根因分析 **文件:`miniprogram/pages/activity-detail/activity-detail.js:108-115`** ```javascript onShareAppMessage() { const activity = this.data.activity || {} return { title: activity.title || '邀请你参加这个活动', path: shareToken ? `...?share_token=${shareToken}` : `...?id=${this.data.id}`, imageUrl: activity.cover_image || '' // ← 问题在这里 } } ``` **问题**:`imageUrl` 使用的是 `activity.cover_image`,这个值在 `normalizeActivity()` 中经过 `resolveImageUrl()` 处理。但存在以下可能: 1. **`cover_image` 为空字符串**:活动未设置封面图,`imageUrl` 为空,微信显示默认卡片 2. **`cover_image` 是相对路径**:虽然 `resolveImageUrl` 会处理,但如果 API 返回的 URL 格式异常,可能解析失败 3. **微信对 imageUrl 的要求**:微信要求图片 URL 必须是 HTTPS,且域名需要在小程序后台配置的 downloadFile 域名白名单中 4. **图片尺寸问题**:微信分享卡片推荐 5:4 比例,过大或过小的图片可能不显示 ### 修复方案 #### 3.1 前端:确保 imageUrl 使用正确的 HTTPS 绝对路径(必须) **文件:`miniprogram/pages/activity-detail/activity-detail.js`** ```javascript onShareAppMessage() { const activity = this.data.activity || {} const shareToken = activity.share_token || this.data.shareToken || '' // 确保使用完整的 HTTPS URL let shareImage = activity.cover_image || '' if (shareImage && !shareImage.startsWith('http')) { shareImage = resolveImageUrl(shareImage) } return { title: activity.title || '邀请你参加这个活动', path: shareToken ? `/pages/activity-detail/activity-detail?share_token=${shareToken}` : `/pages/activity-detail/activity-detail?id=${this.data.id}`, imageUrl: shareImage } } ``` #### 3.2 后端:确保 cover_image 字段返回完整 HTTPS URL(必须) **文件:`backend/app/services/activity_service.py`** 中 `get_activity_detail()` 返回的 `cover_image` 需要经过 `sanitize_media_url()` 和完整 URL 拼接。 检查 `cover_image` 的存储格式: - 如果数据库存的是相对路径(如 `/static/uploads/activities/1/cover.jpg`),需要拼接 `PUBLIC_SITE_URL` - 如果存的是完整 URL,确保是 HTTPS **文件:`backend/app/utils/media.py`** ```python # 确保 sanitize_media_url 返回完整 HTTPS URL def sanitize_media_url(url: str | None) -> str | None: if not url: return None url = _normalize_url(url) # 如果是相对路径,拼接完整 URL if url.startswith('/'): url = f"{settings.public_site_url}{url}" # 确保 HTTPS if url.startswith('http://'): url = url.replace('http://', 'https://', 1) # 检查本地文件是否存在 if '/static/' in url: local_path = _extract_local_path(url) if local_path and not os.path.exists(local_path): return None return url ``` #### 3.3 检查微信小程序后台域名配置(必须) 在 [微信公众平台](https://mp.weixin.qq.com) → 开发 → 开发管理 → 服务器域名中,确保 `downloadFile 合法域名` 包含你的图片域名(如 `https://ghxiangqin.com`)。 #### 3.4 使用网络图片作为 fallback(建议) 如果 `cover_image` 为空或加载失败,使用一张默认的活动封面图: ```javascript // 建议上传一张默认封面到 static 目录 imageUrl: shareImage || 'https://ghxiangqin.com/api/v1/static/uploads/default-share-cover.jpg' ``` --- ## 问题四:嘉宾匹配页面的信息展示切换为与"我的参与"页面一致的列表展示方式 ### 现状对比 | | 匹配页面(match) | 我的参与页面(my-matches) | |--|---|---| | **布局** | 卡片堆叠(deck),左右滑动操作 | 列表式,上下滚动 | | **信息展示** | 头像占满卡片 + 底部浮层显示昵称/年龄/城市 | 头像缩略图 + 右侧文字信息(昵称、状态、城市、学历、爱好) | | **交互** | 左滑跳过、右滑感兴趣、点击查看详情弹窗 | 点击按钮(撤销选择/查看完整资料) | ### 需求理解 将匹配页面的候选人展示从"卡片堆叠"改为"列表式",点击可以查看嘉宾详情,然后再选择是否感兴趣。 ### 修复方案 #### 4.1 修改匹配页面 WXML:卡片布局 → 列表布局 **文件:`miniprogram/pages/match/match.wxml`** 将现有的 `deck-area` 卡片堆叠替换为列表: ```xml 用户 {{item.nicknameText}} {{item.matchScoreText}} {{item.cityText}} · {{item.educationText}} {{item.hobbiesText || item.birthYearRangeText}} {{tag}} {{activeCandidate.nicknameText}} {{activeCandidate.birthYearRangeText}} · {{activeCandidate.cityText}} 匹配度:{{activeCandidate.matchScoreText}} 城市 {{activeCandidate.cityText}} 学历 {{activeCandidate.educationText || '未填写'}} 年龄 {{activeCandidate.birthYearRangeText}} {{item}} 推荐理由 {{activeCandidate.matchReasonsText}} ``` #### 4.2 修改匹配页面 JS:适配列表数据结构 **文件:`miniprogram/pages/match/match.js`** ```javascript // normalizeCandidates 中增加 hobbiesText、educationText normalizeCandidates(list) { return (list || []).map(item => ({ ...item, avatar_url: resolveImageUrl(item.avatar_url || ''), avatar_blur_url: resolveImageUrl(item.avatar_blur_url || ''), nicknameText: item.nickname || '匿名用户', birthYearText: item.birth_year ? `${item.birth_year}年` : '', birthYearRangeText: item.birth_year ? `${new Date().getFullYear() - item.birth_year}岁左右` : '年龄未知', cityText: item.city || '城市未知', educationText: item.education || '学历未知', hobbiesText: Array.isArray(item.hobbies) && item.hobbies.length ? item.hobbies.join('、') : '', personality_tags: item.personality_tags || [], matchScoreText: item.match_score != null ? item.match_score.toFixed(1) : '', matchScorePercent: item.match_score != null ? Math.round(item.match_score) + '%' : '', matchScoreLevel: item.match_score >= 80 ? 'high' : item.match_score >= 60 ? 'mid' : 'low', matchReasonsText: Array.isArray(item.match_reasons) ? item.match_reasons.join(';') : (item.match_reasons || ''), hasMatchReasons: !!(item.match_reasons && (Array.isArray(item.match_reasons) ? item.match_reasons.length : true)), hasMatchScore: item.match_score != null, selected: false })) }, // 打开候选人详情 openCandidateDetail(e) { const index = e.currentTarget.dataset.index const candidate = this.data.candidates[index] if (!candidate) return this.setData({ activeCandidate: candidate, activeCandidatePersonalityText: (candidate.personality_tags || []).join('、') }) }, // 跳过当前候选人 skipCandidate() { const active = this.data.activeCandidate if (!active) return // 从列表中移除 const candidates = this.data.candidates.filter(c => c.user_id !== active.user_id) this.setData({ candidates, activeCandidate: null, currentCandidate: candidates[0] || null, nextCandidate: candidates[1] || null }) }, ``` #### 4.3 修改匹配页面样式 **文件:`miniprogram/pages/match/match.wxss`** ```css /* 列表样式,参考 my-matches */ .candidate-list { padding: 0 16rpx; } .candidate-item { display: flex; align-items: center; background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06); } .candidate-avatar { width: 120rpx; height: 120rpx; border-radius: 50%; flex-shrink: 0; margin-right: 24rpx; } .candidate-content { flex: 1; min-width: 0; } .candidate-name-row { display: flex; align-items: center; gap: 12rpx; margin-bottom: 8rpx; } .candidate-name { font-size: 32rpx; font-weight: 600; color: #333; } .candidate-meta { font-size: 26rpx; color: #888; margin-bottom: 4rpx; } .candidate-tags { margin-top: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; } .candidate-arrow { font-size: 40rpx; color: #ccc; margin-left: 16rpx; } /* 详情弹窗增强 */ .sheet-header { display: flex; align-items: center; margin-bottom: 24rpx; } .sheet-avatar { width: 120rpx; height: 120rpx; border-radius: 50%; margin-right: 20rpx; } .sheet-nickname { font-size: 36rpx; font-weight: 600; } .sheet-meta { font-size: 26rpx; color: #888; margin-top: 4rpx; } .sheet-section { display: flex; padding: 16rpx 0; border-bottom: 1rpx solid #f0f0f0; } .sheet-label { width: 140rpx; color: #888; font-size: 28rpx; flex-shrink: 0; } .sheet-value { flex: 1; font-size: 28rpx; color: #333; } .sheet-actions { display: flex; gap: 20rpx; margin-top: 32rpx; } .sheet-actions .ghost-btn { flex: 1; } .sheet-actions .primary-btn { flex: 2; } ``` --- ## 问题五:匹配详情中隐藏收入,调整字段顺序 ### 现状分析 **文件:`miniprogram/pages/match-detail/match-detail.wxml`** 当前字段顺序:城市 → 学历 → 爱好 → 行业 → 公司 → **收入** → 自我介绍 需求:隐藏收入,按 城市 → 学历 → **年龄** → 爱好 → 行业 → 工作单位 → 自我介绍 排列 ### 修复方案 #### 5.1 修改 WXML:移除收入、调整顺序、增加年龄 **文件:`miniprogram/pages/match-detail/match-detail.wxml`** ```xml 加载中... 头像 {{userInfo.nicknameText}} 匹配时间:{{userInfo.matchedAt}} 城市 {{userInfo.cityText}} 学历 {{userInfo.educationText}} 年龄 {{userInfo.ageText}} 爱好 {{userInfo.hobbiesText}} 行业 {{userInfo.jobIndustryText}} 工作单位 {{userInfo.jobCompanyText}} 自我介绍 {{userInfo.selfIntroText}} ``` #### 5.2 修改 JS:增加年龄计算、移除收入 **文件:`miniprogram/pages/match-detail/match-detail.js`** ```javascript // loadDetail 中修改 userInfo 的构建: this.setData({ userInfo: { ...otherUser, avatar_url: resolveImageUrl(otherUser.avatar_url || ''), avatar_blur_url: resolveImageUrl(otherUser.avatar_blur_url || ''), profileImages, previewImage: profileImages.length ? profileImages[0] : resolveImageUrl(otherUser.avatar_url || ''), nicknameText: otherUser.nickname || '匿名用户', cityText: otherUser.city || '城市未知', educationText: otherUser.education || '学历未知', // 新增:年龄计算 ageText: otherUser.birth_year ? `${new Date().getFullYear() - otherUser.birth_year}岁` : '未填写', hobbiesText: Array.isArray(otherUser.hobbies) && otherUser.hobbies.length ? otherUser.hobbies.join('、') : '未填写爱好', jobIndustryText: otherUser.job_industry || '未填写', jobCompanyText: otherUser.job_company || '未填写', // incomeRangeText 已移除,不再传给前端 selfIntroText: otherUser.self_intro || '暂无自我介绍', matchedAt: detail.matched_at } }) ``` #### 5.3 后端:建议同时从 API 响应中移除收入字段(可选) **文件:`backend/app/routers/activities.py`** 中 `matches_detail` 路由返回的 `other_user` 数据。 如果后端有 `UserResponse` schema,可以在序列化时 exclude `income_range`。或者保留后端不动,仅前端隐藏即可(当前方案)。 --- ## 修复优先级总结 | 优先级 | 问题 | 修复项 | 预计工作量 | |--------|------|--------|-----------| | **P0** | 问题二 | 报名加载卡住 - 前端超时控制 + 防重复点击 | 0.5h | | **P0** | 问题二 | 报名加载卡住 - 后端捕获 IntegrityError | 0.5h | | **P0** | 问题三 | 分享图片不显示 - 确保 imageUrl 为 HTTPS 绝对路径 | 1h | | **P1** | 问题五 | 匹配详情隐藏收入、调整字段顺序 | 1h | | **P1** | 问题四 | 匹配页面改为列表展示 | 3h | | **P1** | 问题一 | 数据库连接池扩容 | 0.5h | | **P2** | 问题一 | uvicorn 多 worker 启动 | 0.5h | | **P2** | 问题一 | 报名接口并发保护(悲观锁/Redis) | 2h | | **P2** | 问题一 | Nginx 反向代理部署 | 2h | --- ## 验证方案 | 问题 | 验证方法 | |------|----------| | 问题一 | 使用压测工具(如 locust)模拟 150 并发登录 + 报名,观察错误率和响应时间 | | 问题二 | 弱网环境下(3G 模拟)点击报名,确认不会一直 loading;快速连续点击 10 次,确认不会重复报名 | | 问题三 | 分享活动到微信聊天,确认卡片显示活动封面图 | | 问题四 | 进入匹配页,确认显示为列表布局;点击嘉宾可查看详情弹窗;弹窗中可选择"加入我的选择" | | 问题五 | 查看匹配详情页,确认无收入字段;字段顺序为:城市→学历→年龄→爱好→行业→工作单位→自我介绍 |