Knonii 内部悬赏任务系统 — 架构方案
定稿于 2026-07-31,基于 7 份飞书/GSAP 调研 + 与 Leo 的需求对齐。 本文档是开工基准,实现中若与此冲突以本文档为准,变更需回写。
#1. 一句话定义
老板(CEO/CTO)在网页端发布带金额和截止日期的悬赏任务,飞书群推卡片通知,员工点进网页抢单(先到先得),完成后贴飞书文档链接提交,老板打星验收,金额计入员工月度账单。
#2. 关键决策(已拍板,不再讨论)
| 决策项 | 结论 | 影响 |
|---|---|---|
| 分配方式 | 抢单制(先到先得) | 靠数据库乐观锁定胜负 |
| 主战场 | 网页端,飞书只做卡片通知 | 飞书集成大幅简化,见 §6 |
| 计价 | 真金额,无积分 | 金额只显示不结算,留审计 |
| 任务描述 | 飞书云文档链接,不做富文本 | 不需要对象存储 |
| 员工提交 | 贴飞书云文档链接 | 同上,R2 砍掉 |
| 打星 | 验收动作,仅老板可见 | 员工看不到自己的星级 |
| 超时 | ddl 到期自动释放回池,无延期申请 | Cron Trigger 驱动 |
| 角色 | superadmin / boss / member 三级 | CEO 与 CTO 无区别,同为 boss |
| 角色分配 | 超管后台手动指派 | 不读飞书通讯录职位字段 |
| 部署 | 全套 Cloudflare | 域名 knonii.com 已托管在 CF |
| UI | shadcn/ui + GSAP 动效爆发 | 不走苹果极简风 |
#3. 技术栈
前端 Next.js 15 App Router + React 19 + TypeScript
样式 Tailwind(底层引擎)+ shadcn/ui + 语义化设计 token
动效 GSAP 3.13(npm 单包,全插件免费)+ @gsap/react + next-themes
后端 Cloudflare Workers(OpenNext adapter)
数据 D1(SQLite)+ Drizzle ORM
缓存 KV(session + 飞书 tenant_access_token)
定时 Cron Triggers(到期释放)
对象存储 无(描述与提交都是飞书文档链接)部署域名:tasks.knonii.com(NS 已在 Cloudflare,直接加记录绑定。⚠️ 别碰根域的 MX / SPF 记录,那是企业微信邮箱在用,且永远保持「仅DNS」灰云)。
项目路径:~/work/knonii-bounty | GitHub:私有仓库,身份 LeoLee0812 / Leo,提交信息用中文。
#3.1 免费版约束与应对(已决定不开 Workers Paid)
免费版三条硬红线,其中两条直接影响架构:
| 限制 | 值 | 应对 |
|---|---|---|
| 单请求 CPU | 10ms | 见下方「SPA 式架构」 |
| Worker 脚本 gzip | 3MB | 控制服务端依赖,Drizzle 很轻;静态资源走 Workers Static Assets 不计入脚本体积 |
| D1 单库 | 500MB / 每天 10 万行写 | 业务量远够;审计日志若增长过快再单独拆表 |
关键设计决定:走 SPA 式架构,而不是重 SSR。
10ms 是 CPU 时间,不含 I/O 等待——D1 查询、fetch 飞书 API 这些等待不计入。真正吃 CPU 的是 React 服务端渲染:页面组件越多、渲染树越深,SSR 越容易撞线。
所以:
- 页面 shell 尽量静态:路由页面本身保持轻量,复杂交互组件全部
'use client',数据通过客户端fetch打 API Routes 拿。SSR 只渲染骨架/加载态,CPU 消耗压到最低。 - API Routes 保持单一职责:一个路由做一件事。典型请求的 CPU 构成 = JWT 验签(jose HS256,微秒级)+ 权限判断(内存比较)+ 序列化 D1 返回值,正常在 1–3ms。
- 不在请求里做重活:不做大批量 JSON 序列化、不做加解密循环、不在服务端跑排序聚合(能用 SQL 的
ORDER BY/GROUP BY就别在 JS 里 sort)。 - 月度账单聚合走 SQL,不要拉全量记录到 JS 里算。
这个取舍和「动效爆发」的产品定位天然契合——GSAP 全部跑在浏览器,本来就不消耗 Worker 的 CPU 预算。
若日后撞线:先看是哪条路由超,通常是列表页;实在不行再开 Paid($5/月把 CPU 提到 30s、脚本提到 10MB),不要为了省这 5 块把代码写扭曲。
#4. 数据模型(D1 / SQLite)
-- 用户表:飞书登录后 upsert
CREATE TABLE users (
id TEXT PRIMARY KEY, -- 内部 uuid
union_id TEXT NOT NULL UNIQUE, -- 飞书 union_id,跨应用稳定,做业务主键
open_id TEXT NOT NULL, -- 当前应用下的 open_id,发消息用
feishu_user_id TEXT, -- 通讯录 member id,调通讯录 API 用
name TEXT NOT NULL,
avatar_url TEXT,
email TEXT,
role TEXT NOT NULL DEFAULT 'member', -- superadmin | boss | member
active INTEGER NOT NULL DEFAULT 1, -- 离职置 0,不删数据
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_users_role ON users(role);
-- 悬赏任务表
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
doc_url TEXT NOT NULL, -- 飞书云文档链接(任务描述)
amount_cents INTEGER NOT NULL, -- 金额,以分为单位存整数,避免浮点
publisher_id TEXT NOT NULL REFERENCES users(id),
deadline INTEGER NOT NULL, -- unix ms,抢单后开始倒计时的终点
status TEXT NOT NULL DEFAULT 'open',
-- open | claimed | submitted | completed | cancelled
claimed_by TEXT REFERENCES users(id),
claimed_at INTEGER,
submit_url TEXT, -- 员工提交的飞书文档链接
submitted_at INTEGER,
rating INTEGER, -- 1-5 星,仅 boss 可见
rated_by TEXT REFERENCES users(id),
rated_at INTEGER,
completed_at INTEGER, -- 验收通过时间,月度账单按此聚合
expire_count INTEGER NOT NULL DEFAULT 0, -- 被超时回收过几次
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_claimed_by ON tasks(claimed_by);
CREATE INDEX idx_tasks_deadline ON tasks(deadline) WHERE status = 'claimed';
CREATE INDEX idx_tasks_completed ON tasks(completed_at) WHERE status = 'completed';
-- 审计日志:金额改动、抢单、超时回收、打星全记
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
actor_id TEXT REFERENCES users(id), -- 系统操作(超时回收)为 NULL
action TEXT NOT NULL,
-- task.create | task.update | task.amount_change | task.claim
-- | task.submit | task.rate | task.expire | task.cancel
-- | user.role_change
target_id TEXT NOT NULL,
before_json TEXT, -- 变更前快照(JSON)
after_json TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_audit_target ON audit_logs(target_id, created_at);
CREATE INDEX idx_audit_actor ON audit_logs(actor_id, created_at);月度账单不建表,直接从 tasks 聚合:
SELECT strftime('%Y-%m', completed_at/1000, 'unixepoch', '+8 hours') AS month,
SUM(amount_cents) AS total, COUNT(*) AS cnt
FROM tasks WHERE status='completed' AND claimed_by=?
GROUP BY month ORDER BY month DESC;时区必须按 +8 处理,否则月初月末的任务会算错月份。
#5. 状态机
发布 抢单(乐观锁) 贴链接提交 打星验收
[–] ──────→ open ──────────────→ claimed ──────────→ submitted ──────→ completed
↑ │ │
│ ddl 到期未提交 │ │ 打星后即完成
└─────────────────────┘ │ (无打回重做)
Cron 自动释放 │
expire_count += 1 │
任何非 completed 状态,boss 可 cancel → cancelled(终态)核心并发点:抢单。必须靠单条 SQL 的原子性定胜负,不能先查后写:
UPDATE tasks SET status='claimed', claimed_by=?, claimed_at=?, updated_at=?
WHERE id=? AND status='open';
-- 影响行数 = 1 → 抢到;= 0 → 已被别人抢走D1 是 SQLite,单库串行写入,这条语句天然原子,不需要额外加锁。
超时释放(Cron Trigger,每分钟跑一次):
UPDATE tasks SET status='open', claimed_by=NULL, claimed_at=NULL,
expire_count = expire_count + 1, updated_at=?
WHERE status='claimed' AND deadline < ?;释放后往飞书群发一张卡片通知原接单人和发布人,同时写 audit_log(actor_id 为 NULL 表示系统操作)。
#6. 飞书集成(大幅简化版)
因为定了「飞书只做卡片通知,抢单在网页」,前期调研里最重的那些东西全部不需要:
| 原本要做 | 现在 | 原因 |
|---|---|---|
card.action.trigger 卡片回调 |
❌ 不做 | 卡片上只有「查看详情」跳转链接,没有交互按钮 |
| Webhook 验签 + AES-CBC 解密 | ❌ 不做 | 没有回调就没有验签 |
| 事件订阅 / 长连接 | ❌ 不做 | 状态变化全在自己库里 |
| Task v2 双向同步 | ❌ 不做 | 员工不在飞书里操作任务 |
| 防回环表 / 幂等表 | ❌ 不做 | 没有双向同步就没有回环 |
| 卡片延迟更新 token | ❌ 不做 | 同上 |
只剩两件事:
#6.1 OAuth 登录(v3 端点)
1. GET https://accounts.feishu.cn/open-apis/authen/v1/authorize
?client_id=&response_type=code&redirect_uri=&state=&scope=offline_access
2. 回调拿 code(5 分钟过期,只能用一次)
3. POST https://accounts.feishu.cn/oauth/v3/token
{grant_type:'authorization_code', client_id, client_secret, code, redirect_uri}
4. GET https://open.feishu.cn/open-apis/authen/v1/user_info
Authorization: Bearer <user_access_token>
5. 按 union_id upsert users 表,签发自己的 session JWT(jose,HS256)写 httpOnly cookie要点:
- 必须申请
offline_accessscope,否则refresh_token永远返回空 - 端点是 v3(
accounts.feishu.cn/oauth/v3/token),不是文档教程页里那个 v2 user_access_token有效期 2h,refresh_token7 天 —— 但我们只在登录时用一次飞书 token 换身份,之后全靠自己的 session JWT(7 天),不需要长期保管用户 token- 不要拿 email/mobile 当登录凭据(官方明确警告,那是管理员导入的未验证数据)
- redirect_uri 必须在开发者后台白名单里逐字符一致(含末尾斜杠),不支持通配符,最多 300 条;本地开发直接加
http://localhost:3000/api/auth/feishu/callback - Edge Runtime:用
jose不用jsonwebtoken,用 Web Crypto 不用node:crypto
#6.2 发群卡片通知
POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id
Authorization: Bearer <tenant_access_token>
{ receive_id, msg_type: 'interactive', content: JSON.stringify(card) }触发时机:① 新任务发布 ② 任务被抢(通知发布人)③ 超时释放(通知原接单人+发布人)④ 提交待验收(通知发布人)
要点:
tenant_access_token有效期 2h,缓存进 KV,剩余 30 分钟内刷新。飞书允许新旧 token 并存一段时间,不需要分布式锁- 机器人必须先被拉进群,否则发送直接失败
- 限速:发消息约 5 QPS
- 不引
@larksuiteoapi/node-sdk(依赖fs,Workers 上跑不了),裸fetch手写一个 100 行的 client
#6.3 需要申请的权限
im:message:send_as_bot 发群消息
contact:user.email:readonly 拿邮箱(可选)
contact:user.employee_id:readonly 拿 user_id(可选,用于后续通讯录联动)
offline_access 拿 refresh_token注意:权限变更必须重新发版本 + 企业管理员审核才生效,联调期间会反复卡这一步,提前规划。
#6.4 飞书文档权限(必须处理)
老板在自己账号下建的文档,员工默认打不开。方案:约定所有悬赏文档放在一个「悬赏任务」知识库里,该库对全员开放,一次性配置,之后零维护。发布任务时前端提示,不做 API 自动授权。
#7. 前端动效映射
| 动效 | 用在哪 | 实现要点 |
|---|---|---|
| Physics2D confetti | 抢单成功那一刻 | 60 片,onComplete 移除 DOM,避免堆积 |
| DrawSVG 描边 | 登录页 / preloader / 首屏主视觉 | 画一笔画五芒星,路径见 §11 |
| flip-clock 倒计时 | 详情页 / 剩余<24h 的卡片 | 见下方性能约束 |
| marquee 跑马灯 | 首页滚任务名(不滚员工名,避免泄露星级) | 内容复制两份,x: 0 → -halfWidth,宽度必须在 useGSAP 里量 |
| preloader | 首屏进入 | demo 是假进度,建议绑真实数据加载 |
| star-rating | 老板端验收(员工不可见) | 改成受控组件,hover 预览态要在 React state 里显式建模 |
| typewriter(已选定) | 首屏标题 | TextPlugin 驱动,光标 opacity yoyo 独立 tween。scramble 不用,备选保留 |
| mouse-trail | 全站常驻 | pointer-events:none 必须保留;matchMedia('(pointer: fine)') 在触屏关闭 |
| theme-toggle | 全站 | next-themes 管状态,GSAP 只做扩散遮罩动画,不能用 GSAP 补间 CSS 变量代替 setTheme |
硬性约束:
- flip-clock 不能每张卡片都跑。50 张卡 × 8 位 × 4 层 = 1600 个 DOM 节点,
preserve-3d会给每个翻页牌单独开合成层,秒位对齐整秒造成「集体翻转」帧峰值。做法:列表页用纯文本 +tabular-nums,翻页牌只给详情页和剩余不足 24h 的卡片;单一gsap.ticker统一驱动所有倒计时,不要每张卡片自己计时;加IntersectionObserver只跑视口内的。 prefers-reduced-motion统一降级,用gsap.matchMedia()封一层。marquee 持续滚动和 mouse-trail 高频闪烁对前庭功能障碍用户有实际健康风险,这不是可选项。- 一律用
useGSAP(),不要手写useEffect+gsap.to()(StrictMode 下会双重挂载产生重复动画)。插件注册集中放lib/gsap.ts模块顶层。 - 黄色主色:logo 黄做强调色(图标、金额、选中态、发光),正文和按钮用深色,因为黄底白字在亮色主题下对比度不达标。
#8. 权限矩阵
| 操作 | superadmin | boss | member |
|---|---|---|---|
| 发布任务 | ✅ | ✅ | ❌ |
| 改金额(未被抢时) | ✅ | ✅ 仅自己发布的 | ❌ |
| 抢单 | ❌ | ❌ | ✅ |
| 提交成果 | ❌ | ❌ | ✅ 仅自己抢的 |
| 打星验收 | ✅ | ✅ | ❌ |
| 看星级 | ✅ | ✅ | ❌ |
| 看全员账单 | ✅ | ✅ | ❌ |
| 看自己账单 | — | — | ✅ |
| 指派角色 | ✅ | ❌ | ❌ |
| 看审计日志 | ✅ | ❌ | ❌ |
已被抢的任务金额锁死,要改必须先 cancel 重发,避免抢单后被改价扯皮。
#8.1 第一个超管怎么产生(引导问题)
所有人首次飞书登录默认都是 member,而只有 superadmin 能指派角色——不处理的话系统上线后没有任何人能提权,死锁。
方案:环境变量 BOOTSTRAP_SUPERADMIN_UNION_ID 指定一个飞书 union_id。登录时若满足「该用户 union_id 匹配 + users 表中尚无任何 superadmin」,则自动赋予 superadmin 并写审计日志。一旦产生第一个超管,此逻辑永久失效(靠 COUNT(role='superadmin')=0 的条件保证),不会被后续登录重复触发。
不采用「第一个登录的人自动成为超管」——竞态下谁先点谁成超管,风险太大。
⚠️ 原设计是用邮箱匹配,实测行不通,已改 union_id。 2026-07-31 跑了两轮完整登录链路验证:
- 第一轮 scope 只传
offline_access→ 下发 scope 为auth:user.id:read offline_access,- 第二轮 scope 显式加
contact:user.email:readonly→ 飞书接受了该 scope(下发 scope 确实包含它,没报 20027), 但enterprise_email为null结论:不是权限问题,是该飞书账号在通讯录里本来就没有邮箱数据。公司其他员工可能有、可能没有, 所以 email 字段不可依赖,绝不能作为身份匹配依据。union_id 无需任何额外权限就返回, 且本来就是 users 表的唯一键。
实现要求:scope 里仍然带上
contact:user.email:readonly(成本为零,有邮箱的用户能顺带存下来), 但代码里
#9. 开工顺序
- 飞书开发者后台建应用、配权限、加 redirect_uri 白名单、拉机器人进群(需要企业管理员配合)
- Cloudflare 建 Worker + D1 + KV,绑
tasks.knonii.com(免费版,不开 Paid) - Next.js 骨架 + OpenNext 适配 + shadcn 初始化 + 设计 token(黄色强调色体系)
- 飞书 OAuth 登录闭环(最先跑通,因为它卡住所有后续开发)
- 数据层(Drizzle schema + migration + 统一 data layer,所有查询收口,强制带权限过滤)
- 任务 CRUD + 抢单乐观锁 + 状态机
- 飞书卡片通知
- Cron 超时释放
- 月度账单聚合页
- GSAP 动效层(放最后,因为它不阻塞任何业务逻辑)
#10. 飞书通知文案(默认版,上线前可调)
四个触发点的卡片文案。卡片上只放一个「查看详情」按钮跳 tasks.knonii.com,不做交互按钮。
| 触发 | 发给谁 | 文案 |
|---|---|---|
| 新任务发布 | 通知群 | 🎯 新悬赏|{标题} 金额 ¥{金额} ・ 截止 {MM-DD HH:mm} 发布人 {发布人} [查看详情并抢单] |
| 被抢单 | 发布人(私聊或群内) | ✋ {接单人} 接下了「{标题}」 截止 {MM-DD HH:mm} [查看详情] |
| 提交待验收 | 发布人 | 📮 {接单人} 提交了「{标题}」 等你验收打星 [去验收] |
| 超时释放 | 原接单人 + 发布人 | ⏰ 「{标题}」已到期未提交,已自动释放回悬赏池 第 {expire_count} 次释放 [查看详情] |
原则:不在卡片里写金额以外的敏感信息,星级永远不进卡片(星级只在网页端对老板可见)。
#10.1 待确认
- 无(全部决策已锁定,可开工)
#10.2 页面清单与角色视图
同一个域名、同一套路由,靠登录后的角色渲染不同界面,不做两个独立站点。
| 路由 | 谁能进 | 老板(boss/superadmin)看到 | 员工(member)看到 |
|---|---|---|---|
/ 首页 |
全员 | 待验收数量、我发布的进行中任务 | 可抢悬赏数、我的进行中任务 |
/tasks 悬赏池 |
全员 | 只读浏览 | 「抢单」按钮 |
/tasks/[id] 详情 |
全员 | 编辑/取消(仅自己发布且未被抢)、星级 | 抢单或提交入口,看不到星级 |
/my 我的任务 |
member | — | 我抢的任务 + 翻页倒计时 + 提交入口 |
/publish 发布 |
boss+ | 发布表单(标题/文档链接/金额/ddl) | 无此入口 |
/review 待验收 |
boss+ | submitted 队列 + 打星验收 | 无此入口 |
/billing 账单 |
全员 | 全员月度账单,按人分组 | 只有自己的 |
/admin 管理 |
superadmin | 角色指派、审计日志 | 无 |
动效实际上分布在两侧,不是所有人都能看到所有效果:
| 动效 | 只有谁看得到 |
|---|---|
| confetti 撒花 | 员工(抢单成功那一刻)——老板永远看不到 |
| star-rating 打星 | 老板(验收时)——员工永远看不到 |
| flip-clock 翻页倒计时 | 两侧都有,但员工侧最重要(自己的任务快到期) |
| 五芒星 preloader / typewriter / marquee / theme-toggle / mouse-trail | 全员共用 |
前端实现要点:角色判断从 session JWT 里读,服务端和客户端都要判——服务端负责不把数据发出去(比如 rating 字段对 member 根本不序列化),客户端负责不渲染入口。只做客户端隐藏是不够的,那只是 UI 层面,API 照样能被直接调。
#11. 五芒星主视觉(已定)
不用 knonii 品牌 logo,改用一笔画五芒星做 DrawSVG 描边动画的对象。
路径(中心 200×200 画布的 (100,100),外接圆半径 80,五个等分点按「隔一个连一个」连接):
M100,20 L147.02,164.72 L23.92,75.28 L176.08,75.28 L52.98,164.72 Z选它的理由:
- 是一条真正连续的单笔路径,DrawSVG 从 0%→100% 就是「一笔画完」,比实心五角星轮廓(沿外形描一圈)更有手绘仪式感
- 只有 5 段直线 + 闭合,锚点极少,画出来速度绝对均匀。位图自动描摹出来的路径动辄几百个冗余锚点,画起来会一顿一顿的——这正是放弃 PNG 矢量化的额外收益
- 五芒星本身就是「悬赏 / 星级 / 评价」的语义符号,和 star-rating 验收打星呼应
已选定:方案三「旋转入场 + 辉光」(预览文件 ~/Desktop/Knonii五芒星预览/五芒星-DrawSVG-三种方案.html)。
时间线:描边 drawSVG 0%→100%(1.5s,power2.inOut)与 rotation -360°→0° 同时起跑,描边收尾前 0.2s 开始 fillOpacity 淡入(0.45s),最后 scale 1→1.15→1 用 elastic.out(1, 0.4) 回弹定格。整条路径带 drop-shadow(0 0 12px rgba(255,216,77,.55)) 辉光。
gsap.set(el, { fillOpacity: 0, scale: 1, rotation: -360, transformOrigin: '50% 50%' });
const tl = gsap.timeline();
tl.fromTo(el, { drawSVG: '0%' }, { drawSVG: '100%', duration: 1.5, ease: 'power2.inOut' })
.to(el, { rotation: 0, duration: 1.5, ease: 'power2.inOut' }, 0)
.to(el, { fillOpacity: 1, duration: 0.45, ease: 'power1.out' }, '-=0.2')
.to(el, { scale: 1.15, duration: 0.22, ease: 'power2.out' })
.to(el, { scale: 1, duration: 0.7, ease: 'elastic.out(1, 0.4)' });另外两版(纯描边、描边+填色)保留在预览文件里,如果 preloader 需要更轻的版本可以取用。
品牌黄取 #FFD84D,只用于强调(描边、填充、辉光),正文与按钮走深色底,避免亮色主题下黄底白字的对比度问题。
来源:沉淀/03-项目方案与交接/Knonii悬赏任务系统-架构方案.md(整理于 2026-08-18)