/blog/post-1786032522011/博客
READY
技术笔记

小组协作积分小程序开发手记:从 Bug 修复到四大功能落地

2026-08-06约 9 分钟

引子

今天的工作涵盖了一层完整的开发横切面:从线上事故排查、第三方服务对接踩坑、到四个中等复杂度的功能模块并行交付。整个过程踩了不少细节,记下来备忘,也供有类似场景的同学参考。


Part 1:线上事故排查 — COS AccessDenied 导致服务崩溃

现象

云托管日志中频繁出现以下堆栈尾部:

File "/usr/local/lib/python3.12/site-packages/starlette/_utils.py", line 82, in collapse_excgroups
    self.gen.throw(value)

日志不完整,没有显式报错信息,但服务反复重启。

定位过程

堆栈倒推:collapse_excgroups → 中间件层异常渗出 → 请求处理中发生未捕获异常。

检查 auth.py 的 upload-avatar 端点:

python

复制

# 修复前
client.put_object(Bucket=settings.COS_BUCKET, Body=content, Key=key)
# ↑ COS SDK 抛出 CosServiceError 没有被捕获,直接穿透所有中间件

对比 auth_service.py 中登录流程的同一段逻辑——它是有 try/except 的。问题根源是一个不对称防御漏洞:同类型的 COS 操作在不同代码路径中存在差异化的异常处理。

修复

在所有 COS SDK 直接调用处统一包裹异常保护:

python

复制

# auth.py / upload-avatar
try:
    content = base64.b64decode(b64)
    client.put_object(...)
except Exception as e:
    logger.warning(f"头像上传 COS 失败: {e}")
    return R(code=500, message="头像上传失败,请稍后重试")

同时修复 cos_client.py 中的 upload_to_cos 工具函数,两处加 try/except 形成纵深防御。

后续踩坑:Bucket Policy vs CAM 权限

异常保护只是"不崩溃",但 COS 仍然返回 AccessDenied。排查发现两个独立问题:

  1. 环境变量填错桶名:变量 COS_BUCKET 指向云托管自动生成的桶 7072-prod-...,但 Bucket Policy 配在了手动创建的 group-points-1333612437 上——完全两个桶。

  2. Region 不匹配:变量填了 ap-shanghai,但目标桶在广州(ap-guangzhou)。COS 的 Region 不对会导致请求路由到错误节点,直接拒绝。

更正环境变量后上传恢复正常。

教训:COS 鉴权是两层——Bucket Policy(门禁规则)+ CAM 子账号权限(门禁卡)。主账号虽然天然有全量权限,但桶名和 Region 任何一个不对都会 AccessDenied。


Part 2:wx.showLoading 配对警告

现象

微信开发者工具控制台反复输出:

请注意 showLoading 与 hideLoading 必须配对使用

调用栈指向 edit.js 的 onChooseAvatar 方法。

根因

wx.showLoading 内部是异步执行的。原来的 async/await 写法中,wx.showLoading() 和 wx.hideLoading() 之间存在不可控的时序间隙——网络请求可能极快返回,hideLoading 在 showLoading 真正生效之前就到了。

另一个潜在问题是用户快速连点,触发多次 onChooseAvatar,导致多个 showLoading 同时存在。

最终方案

放弃 showLoading/hideLoading 对,改用 wx.showToast({ icon: 'loading' }) + wx.hideToast(),这两个不需要配对约束。同时加 _uploading 互斥锁防重复触发。

javascript

复制

onChooseAvatar(e) {
    if (this._uploading) return;
    this._uploading = true;

    wx.showToast({ title: '上传中...', icon: 'loading', duration: 10000 });

    try {
        // ... upload logic ...
        wx.hideToast();
    } catch (err) {
        wx.hideToast();
    } finally {
        this._uploading = false;
    }
}

Part 3:Redis 缓存落地

设计思路

项目最初在 requirements.txt 中已引入 redis==5.2.1,但一直没写连接管理和缓存层。现在补上,设计原则:

  • 降级友好:Redis 未连接时缓存操作静默跳过,不阻塞业务
  • 薄封装:只用 get/setex/delete/scan,不依赖重型缓存框架
  • Cache-aside 模式:业务代码显式控制缓存读写和失效

核心实现

连接管理 (core/redis.py):

python

复制

import redis.asyncio as aioredis

_redis_pool: aioredis.Redis | None = None

async def get_redis() -> aioredis.Redis:
    global _redis_pool
    if _redis_pool is None:
        _redis_pool = await aioredis.from_url(
            url, encoding="utf-8", decode_responses=True, max_connections=20
        )
        await _redis_pool.ping()
    return _redis_pool

缓存工具层:cache_get / cache_set / cache_delete / cache_delete_pattern,全部内置 try/except 异常静默。

装饰器 @cached(ttl=300):自动缓存异步函数返回值,key 指纹 = 函数名 + 参数值。智能跳过 SQLAlchemy session 对象(避免把 db session 序列化进 key)。

python

复制

@cached(ttl=60, prefix="project")
async def get_project_detail(db, project_id):
    ...

业务接入

首期接入 项目列表查询:

  • 查询时:先查缓存 → 命中直接返回 → 未命中查 DB 并回写
  • 写入时:创建项目、成员变更时主动 cache_delete 失效
  • TTL:120 秒(2 分钟),兼顾实时性和 DB 保护

python

复制

# project_service.py 中的缓存逻辑
cache_key = f"projects:my:{user_id}"
cached = await cache_get(cache_key)
if cached:
    return [Project(**p) for p in cached]

# ... DB 查询 ...

await cache_set(cache_key, cache_data, ttl=120)

应用启动(main.py lifespan)中初始化 Redis 连接,失败时只打 warning 不阻断启动。


Part 4:项目自动状态变更

需求

项目设有 deadline(截止日期),到期后应自动从 active 变为 ended。这在传统后端中通常靠 Celery Beat / APScheduler 实现,但我们项目不想引入额外组件。

方案:asyncio 原生后台任务

FastAPI 的 lifespan 上下文管理器可以启动 asyncio task,完美替代 cron:

python

复制

async def _scheduler_loop():
    while True:
        await asyncio.sleep(300)  # 5 分钟
        try:
            async with AsyncSessionLocal() as db:
                result = await db.execute(
                    update(Project)
                    .where(
                        Project.status == "active",
                        Project.deadline.isnot(None),
                        Project.deadline < func.now(),
                    )
                    .values(status="ended")
                )
                await db.commit()
                if result.rowcount:
                    logger.info(f"{result.rowcount} 个项目已自动结束")
        except Exception as e:
            logger.warning(f"定时任务异常: {e}")

在 lifespan 中启动并在 shutdown 时 cancel:

python

复制

@asynccontextmanager
async def lifespan(app):
    # ... init ...
    scheduler_task = asyncio.create_task(_scheduler_loop())
    yield
    scheduler_task.cancel()
    await scheduler_task  # 优雅等待取消

优点:零外部依赖、和 FastAPI 同进程、自动随服务启停。适用条件:单实例部署(云托管确实只有一个实例)。


Part 5:操作动态流

设计

新增 activities 表作为事件溯源:

python

复制

class Activity(Base):
    __tablename__ = "activities"

    id            = Column(Integer, primary_key=True)
    project_id    = Column(Integer, ForeignKey("projects.id"), index=True)
    user_id       = Column(Integer, ForeignKey("users.id"))
    action_type   = Column(String(32))    # create_project / create_task / claim_task / submit_review / review_approve / member_join
    target_type   = Column(String(32))    # project / task / member
    target_id     = Column(Integer)
    description   = Column(String(512))   # 人类可读的描述文本
    meta_json     = Column(Text)          # 结构化扩展数据 (JSON)
    created_at    = Column(DateTime, default=func.now(), index=True)

    # 复合索引:按项目+时间查动态流
    __table_args__ = (
        Index("ix_activities_project_time", "project_id", "created_at"),
    )

关键设计决策

flush 而非 commit:create_activity 只做 db.flush(),把 commit 留给外层路由的事务边界。这样如果业务操作失败回滚,动态记录也一并回滚,保证数据一致性。

静态 description:动态文本直接存为字符串(如 "📋 发布了新任务「设计评审」"),不依赖 JOIN 查询。这避免了"用户改名后历史动态文字变化"的问题——和审计日志一样,记录的是当时的快照。

埋点位置

在以下业务节点植入 create_activity 调用:

节点action_type触发时机创建项目create_projectproject_service.create_project发布任务create_tasktask_service.create_task接取任务claim_tasktask_service.claim_task提交审核submit_reviewtask_service.submit_review / review_service审核通过review_approvereview_service.process_review审核驳回review_rejectreview_service.process_review成员加入member_joinproject_service.review_join_request

API & 前端

  • API:GET /api/v1/projects/{id}/activities?page=1&page_size=30,分页返回时间倒序的动态列表
  • 前端:时间线样式卡片,左竖线 + 彩色圆点 + 描述文本 + 时间戳,下拉刷新 + 触底加载更多
  • 入口:项目详情页 Tab 栏新增"动态"按钮

Part 6:Canvas 图表性能优化

问题

原有的 Canvas 2D 图表实现了基本功能,但存在几个问题:

  1. 无条件重绘:每次 setData 回调都重新绘制,即使数据没变
  2. 无动画:折线图和饼图直接全量渲染,视觉上太生硬
  3. 未清理定时器:页面切换后动画 timer 继续跑,浪费资源

优化方案

1. 数据指纹缓存

javascript

复制

drawTrendChart() {
    const fingerprint = JSON.stringify(data);
    if (this._lastRendered === fingerprint) return;
    this._lastRendered = fingerprint;
    // ... 继续渲染
}

用 JSON 序列化作为数据指纹,相同数据直接跳过。简单但有效——对于图表场景,数据不变化时没必要重绘。

2. 帧动画

折线图用 30 帧 ease-out 入场:

javascript

复制

const totalFrames = 30;
let frame = 0;

const animate = () => {
    const progress = Math.min(frame / totalFrames, 1);
    const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
    const visibleCount = Math.ceil(points.length * eased);

    // 只绘制已出现的线段和填充区域
    // ...

    frame++;
    if (frame <= totalFrames) {
        this._animTimer = setTimeout(animate, 16); // 60fps
    }
};

饼图同理,扇形角度从 0 逐渐展开到完整角度(20 帧 + ease-out quadratic)。

3. 生命周期管理

onHide 中取消动画:

javascript

复制

onHide() {
    if (this._animTimer) clearTimeout(this._animTimer);
    this._animTimer = null;
}

防止页面切走后后台还在跑渲染。

4. 渲染路径优化

  • 折线区域 + 数据点在一次 path 中完成,减少 GPU 状态切换
  • 渐变填充用 createLinearGradient 预计算
  • 避免不必要的 save/restore 栈操作
  • 标签线在动画完成度 > 80% 时才绘制,减少闪烁

工程体会

  1. 不对称防御是 Bug 的温床:同一操作在不同代码路径有不同异常处理 → 必然漏网。统一工具函数 + 统一异常策略才是正解。

  2. 第三方鉴权排查要有系统思维:COS AccessDenied 可能的原因不止一种——桶名、Region、Bucket Policy、CAM 权限、密钥错误都需要排查,不要停在第一个假设上。

  3. asyncio 原生的力量被低估了:很多人一说到定时任务就上 Celery,但在单实例场景下,asyncio.create_task + while sleep 足矣,省掉整个组件的运维成本。

  4. 缓存层要"温顺":每个操作都 try/except,Redis 挂了自动降级,不做强依赖。这是所有缓存方案的黄金法则。

  5. Canvas 动画不是花活:在小程序这种单线程环境,入场动画能让用户感知"数据在加载"而不是"卡住了",有一定实际价值。但必须做好定时器清理,否则是定时炸弹。

关联项目

小组协作积分小程序

基于微信小程序的团队协作管理工具,通过任务-积分闭环提升协作效率。