xiangqinxiaochengxu/小程序问题修复方案.md
2026-06-18 23:06:40 +08:00

25 KiB
Raw Blame History

小程序问题修复方案

日期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 启动(必须)

# 启动命令改为:
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
# workers = CPU 核心数 * 2 + 14核机器建议 4-8 个 worker

1.2 扩大数据库连接池(必须)

文件:backend/app/database.py

# 当前:
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 人并发报名可能导致超额。

# 方案 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
# 方案 B更优用 Redis 原子计数器做名额预检
# 报名前先 Redis DECR成功才写数据库失败回滚
# 适合极高并发场景,实现稍复杂

1.4 调整登录限流策略(建议)

文件:backend/app/routers/auth.py

当前限流是 per IP 的,公司 WiFi 下 150 人共用一个 IP 会被限流。

# 建议改为 per openid 限流,或放宽 IP 限流
# 方案:改为 60次/60秒/IP或改为 per openid 5次/60秒

1.5 Nginx 反向代理(生产必须)

当前无 Nginxuvicorn 直接对外服务。生产环境应加 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

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}。 在弱网下这意味着两个串行请求,耗时翻倍。

// 优化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

# 当前代码在高并发下的问题:
# 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 后端:添加请求超时日志(排查用)

registerbind 路由中加日志,记录请求耗时:

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

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

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.pyget_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

# 确保 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 检查微信小程序后台域名配置(必须)

微信公众平台 → 开发 → 开发管理 → 服务器域名中,确保 downloadFile 合法域名 包含你的图片域名(如 https://ghxiangqin.com)。

3.4 使用网络图片作为 fallback建议

如果 cover_image 为空或加载失败,使用一张默认的活动封面图:

// 建议上传一张默认封面到 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 卡片堆叠替换为列表:

<!-- 替换原来的 deck-area 部分 -->
<view wx:if="{{candidates.length}}" class="candidate-list">
  <view
    wx:for="{{candidates}}"
    wx:key="user_id"
    class="candidate-item"
    data-index="{{index}}"
    bindtap="openCandidateDetail"
  >
    <image wx:if="{{item.avatar_url}}" class="candidate-avatar" src="{{item.avatar_url}}" mode="aspectFill" />
    <view wx:else class="candidate-avatar avatar-placeholder">用户</view>
    <view class="candidate-content">
      <view class="candidate-name-row">
        <view class="candidate-name">{{item.nicknameText}}</view>
        <view wx:if="{{item.hasMatchScore}}" class="score-chip score-chip--{{item.matchScoreLevel}}">
          {{item.matchScoreText}}
        </view>
      </view>
      <view class="candidate-meta">{{item.cityText}} · {{item.educationText}}</view>
      <view class="candidate-meta">{{item.hobbiesText || item.birthYearRangeText}}</view>
      <view wx:if="{{item.personality_tags.length}}" class="candidate-tags">
        <text wx:for="{{item.personality_tags}}" wx:for-item="tag" wx:key="*this" class="tag">{{tag}}</text>
      </view>
    </view>
    <view class="candidate-arrow"></view>
  </view>
</view>

<!-- 详情弹窗(保留并增强) -->
<view wx:if="{{activeCandidate}}" class="mask" bindtap="closeCard">
  <view class="sheet" catchtap="noop">
    <view class="sheet-header">
      <image wx:if="{{activeCandidate.avatar_url}}" class="sheet-avatar" src="{{activeCandidate.avatar_url}}" mode="aspectFill" />
      <view class="sheet-header-info">
        <view class="sheet-nickname">{{activeCandidate.nicknameText}}</view>
        <view class="sheet-meta">{{activeCandidate.birthYearRangeText}} · {{activeCandidate.cityText}}</view>
      </view>
    </view>

    <view wx:if="{{activeCandidate.hasMatchScore}}" class="sheet-score">
      匹配度:{{activeCandidate.matchScoreText}}
    </view>

    <view class="sheet-section">
      <view class="sheet-label">城市</view>
      <view class="sheet-value">{{activeCandidate.cityText}}</view>
    </view>
    <view class="sheet-section">
      <view class="sheet-label">学历</view>
      <view class="sheet-value">{{activeCandidate.educationText || '未填写'}}</view>
    </view>
    <view class="sheet-section">
      <view class="sheet-label">年龄</view>
      <view class="sheet-value">{{activeCandidate.birthYearRangeText}}</view>
    </view>

    <view wx:if="{{activeCandidate.personality_tags.length}}" class="sheet-tags">
      <text wx:for="{{activeCandidate.personality_tags}}" wx:key="*this" class="tag">{{item}}</text>
    </view>

    <view wx:if="{{activeCandidate.hasMatchReasons}}" class="sheet-reasons">
      <view class="sheet-reasons-title">推荐理由</view>
      <view class="sheet-reasons-text">{{activeCandidate.matchReasonsText}}</view>
    </view>

    <view class="sheet-actions">
      <button class="ghost-btn" bindtap="skipCandidate">跳过</button>
      <button class="primary-btn" data-id="{{activeCandidate.user_id}}" bindtap="likeUser">加入我的选择</button>
    </view>
  </view>
</view>

4.2 修改匹配页面 JS适配列表数据结构

文件:miniprogram/pages/match/match.js

// 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

/* 列表样式,参考 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

<view class="page detail-page">
  <view wx:if="{{loading}}" class="loading">加载中...</view>
  <view wx:elif="{{userInfo}}" class="card">
    <view class="header">
      <image wx:if="{{userInfo.previewImage}}" class="avatar" src="{{userInfo.previewImage}}" mode="aspectFill" />
      <view wx:else class="avatar avatar-placeholder">头像</view>
      <view class="base-info">
        <view class="nickname">{{userInfo.nicknameText}}</view>
        <view class="matched-at">匹配时间:{{userInfo.matchedAt}}</view>
      </view>
    </view>

    <view wx:if="{{userInfo.profileImages.length}}" class="gallery-strip">
      <image wx:for="{{userInfo.profileImages}}" wx:key="*this" class="gallery-image" src="{{item}}" mode="aspectFill" />
    </view>

    <!-- 1. 城市 -->
    <view class="section">
      <view class="label">城市</view>
      <view class="value">{{userInfo.cityText}}</view>
    </view>
    <!-- 2. 学历 -->
    <view class="section">
      <view class="label">学历</view>
      <view class="value">{{userInfo.educationText}}</view>
    </view>
    <!-- 3. 年龄(新增) -->
    <view class="section">
      <view class="label">年龄</view>
      <view class="value">{{userInfo.ageText}}</view>
    </view>
    <!-- 4. 爱好 -->
    <view class="section">
      <view class="label">爱好</view>
      <view class="value">{{userInfo.hobbiesText}}</view>
    </view>
    <!-- 5. 行业 -->
    <view class="section">
      <view class="label">行业</view>
      <view class="value">{{userInfo.jobIndustryText}}</view>
    </view>
    <!-- 6. 工作单位 -->
    <view class="section">
      <view class="label">工作单位</view>
      <view class="value">{{userInfo.jobCompanyText}}</view>
    </view>
    <!-- 7. 自我介绍 -->
    <view class="section">
      <view class="label">自我介绍</view>
      <view class="value intro">{{userInfo.selfIntroText}}</view>
    </view>
    <!-- 收入已隐藏 -->
  </view>
</view>

5.2 修改 JS增加年龄计算、移除收入

文件:miniprogram/pages/match-detail/match-detail.js

// 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.pymatches_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 次,确认不会重复报名
问题三 分享活动到微信聊天,确认卡片显示活动封面图
问题四 进入匹配页,确认显示为列表布局;点击嘉宾可查看详情弹窗;弹窗中可选择"加入我的选择"
问题五 查看匹配详情页,确认无收入字段;字段顺序为:城市→学历→年龄→爱好→行业→工作单位→自我介绍