📚 离职知识库

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-bountyGitHub:私有仓库,身份 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_access scope,否则 refresh_token 永远返回空
  • 端点是 v3(accounts.feishu.cn/oauth/v3/token),不是文档教程页里那个 v2
  • user_access_token 有效期 2h,refresh_token 7 天 —— 但我们只在登录时用一次飞书 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

硬性约束

  1. flip-clock 不能每张卡片都跑。50 张卡 × 8 位 × 4 层 = 1600 个 DOM 节点,preserve-3d 会给每个翻页牌单独开合成层,秒位对齐整秒造成「集体翻转」帧峰值。做法:列表页用纯文本 + tabular-nums,翻页牌只给详情页和剩余不足 24h 的卡片;单一 gsap.ticker 统一驱动所有倒计时,不要每张卡片自己计时;加 IntersectionObserver 只跑视口内的。
  2. prefers-reduced-motion 统一降级,用 gsap.matchMedia() 封一层。marquee 持续滚动和 mouse-trail 高频闪烁对前庭功能障碍用户有实际健康风险,这不是可选项。
  3. 一律用 useGSAP(),不要手写 useEffect + gsap.to()(StrictMode 下会双重挂载产生重复动画)。插件注册集中放 lib/gsap.ts 模块顶层。
  4. 黄色主色: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_accessemail 为空字符串
  • 第二轮 scope 显式加 contact:user.email:readonly飞书接受了该 scope(下发 scope 确实包含它,没报 20027), 但 email 仍然是空字符串enterprise_emailnull

结论:不是权限问题,是该飞书账号在通讯录里本来就没有邮箱数据。公司其他员工可能有、可能没有, 所以 email 字段不可依赖,绝不能作为身份匹配依据。union_id 无需任何额外权限就返回, 且本来就是 users 表的唯一键。

实现要求:scope 里仍然带上 contact:user.email:readonly(成本为零,有邮箱的用户能顺带存下来), 但代码里 email 必须按可空处理,任何逻辑都不许依赖它。

#9. 开工顺序

  1. 飞书开发者后台建应用、配权限、加 redirect_uri 白名单、拉机器人进群(需要企业管理员配合
  2. Cloudflare 建 Worker + D1 + KV,绑 tasks.knonii.com(免费版,不开 Paid)
  3. Next.js 骨架 + OpenNext 适配 + shadcn 初始化 + 设计 token(黄色强调色体系)
  4. 飞书 OAuth 登录闭环(最先跑通,因为它卡住所有后续开发)
  5. 数据层(Drizzle schema + migration + 统一 data layer,所有查询收口,强制带权限过滤)
  6. 任务 CRUD + 抢单乐观锁 + 状态机
  7. 飞书卡片通知
  8. Cron 超时释放
  9. 月度账单聚合页
  10. 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→1elastic.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)