feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档

- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件
- alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT
- tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行)
- web: React18+Vite 骨架(登录、工作区、邀请落地页)
- android: Compose 骨架(登录、工作区列表)
- 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
Steven
2026-10-28 02:38:53 +08:00
commit 0c53aa7062
72 changed files with 5320 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.webp binary
*.ttf binary
*.jar binary
*.zip binary
+59
View File
@@ -0,0 +1,59 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres_dev_pw
POSTGRES_DB: dadaotask
ports: ["5433:5432"]
options: >-
--health-cmd "pg_isready -U postgres -d dadaotask"
--health-interval 5s --health-timeout 3s --health-retries 20
env:
ENV: test
DATABASE_URL: postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask
MIGRATION_DATABASE_URL: postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# 关键:先建角色再跑迁移。应用角色必须是受限角色,否则 RLS 测试会"假通过"。
- name: 创建数据库角色
run: |
PGPASSWORD=postgres_dev_pw psql -h 127.0.0.1 -p 5433 -U postgres -d dadaotask \
-v ON_ERROR_STOP=1 -f deploy/postgres/init/01-roles.sql
- name: 安装依赖
working-directory: backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint
working-directory: backend
run: |
ruff check .
ruff format --check .
- name: 迁移
working-directory: backend
run: alembic upgrade head
- name: 测试
working-directory: backend
run: pytest -q
+37
View File
@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
*.egg-info/
# 环境与密钥:绝不进仓库
.env
.env.local
*.pem
*.key
# 前端
node_modules/
web/dist/
web/.vite/
# 安卓
android/.gradle/
android/build/
android/app/build/
android/local.properties
# 编辑器 / 系统
.DS_Store
.idea/
.vscode/
# pip 安装过程中的临时目录(Windows 上出现在 cwd)
pip-unpack-*/
pip-req-build-*/
tmprfx*
+98
View File
@@ -0,0 +1,98 @@
# 开发环境(M0)
> 目标:新人在 20 分钟内把后端跑起来并让 `pytest` 全绿。
## 1. 依赖
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.10+(CI 用 3.11) | 后端 |
| Node | 20+ | 前端 |
| Docker Desktop | 任意近期版本 | 只用来跑 Postgres / Redis |
## 2. 端口约定(别改用默认端口)
| 服务 | 端口 | 为什么 |
|---|---|---|
| PostgreSQL | **5433** | 避开机器上可能已有的 5432 |
| Redis | **6380** | 同上 |
| 后端 API | **8100** | 前端 dev 代理指向它 |
| 前端 dev | **5180** | 与 CORS 默认值一致 |
## 3. 起基础设施
```bash
cd deploy
docker-compose -f docker-compose.dev.yml up -d # 老版 CLI 是 docker-compose,新版是 docker compose
```
首次初始化会自动执行 `deploy/postgres/init/01-roles.sql`,创建两个角色:
- `dadao_owner` —— 拥有库、可以建表,**只有 alembic 用**。
- `dadao_app` —— 应用连接用,无 DDL、无 BYPASSRLS,行级安全对它生效。
> 为什么必须两个角色:PostgreSQL 的 RLS 会对表 owner 和 superuser **直接跳过**。
> 如果应用用 owner 连库,多租户隔离就是摆设,而且测试会"假通过"。
## 4. 后端
```bash
cd backend
python -m venv .venv
.venv\Scripts\activate # Linux/macOS: source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # Windows: copy .env.example .env
alembic upgrade head # 建表 + RLS 策略(人工复核过再上)
uvicorn app.main:app --reload --port 8100
```
自检:
```bash
curl http://127.0.0.1:8100/health
curl http://127.0.0.1:8100/version # commit 必须等于 git rev-parse --short HEAD
```
开发环境验证码固定为 `SMS_DEV_CODE`(默认 `123456`),且 `/auth/sms/send` 会把 `dev_code` 回显在响应里——
**只有 `ENV=dev` 才允许**,`ENV=prod` 时启动即失败。
## 5. 前端
```bash
cd web
npm install
npm run dev # http://127.0.0.1:5180,/api 代理到 8100
npm run build # 产物必须在仓库 HEAD 上构建,禁止本地构建后上传
```
## 6. 测试
```bash
cd backend
pytest -q # 必须全绿才能提交
ruff check . && ruff format --check .
```
测试会真连数据库、真跑迁移、真走 HTTP 栈。跑之前确保 Postgres 在 5433。
隔离测试(`tests/test_tenant_isolation.py`)是本仓库最重要的一组用例,改动任何租户表都必须重跑它。
## 7. 常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| `pytest` 报 `attached to a different loop` | 连接池跨事件循环复用 | 测试环境已用 NullPool,别改回去 |
| 迁移报 `permission denied for schema public` | 用的是应用角色 | `MIGRATION_DATABASE_URL` 必须指向 owner |
| 接口返回空列表而不是报错 | 忘了 `bind_tenant`,RLS 过滤掉了一切 | 走 `MemberDep` / `TenantDep` 依赖 |
| `pip` 报 `UnicodeDecodeError: 'gbk' codec` | requirements.txt 里有非 ASCII | 该文件保持纯 ASCII |
| `docker compose` 报 `unknown command` | 只装了 compose v1 | 用 `docker-compose` |
| Docker 起不来 | Docker Desktop 未启动 / 需要管理员 | 先启动 Desktop,`docker info` 有输出再继续 |
## 8. 提交前自检清单
1. `pytest` 全绿,`ruff` 无告警。
2. 新增租户表时:`workspace_id NOT NULL` + RLS 策略 + 跨租户测试,三者齐全。
3. 迁移文件提交前人工复核(`alembic revision --autogenerate` 只做草稿)。
4. 改了接口就更新 `2.Design/API.md`(M1 建立该文档)。
5. 推送到远端,生产部署后 `curl /version` 校验 commit。
+109
View File
@@ -0,0 +1,109 @@
# DadaoTask · 中文命名方案
> 工程名已经是 `DadaoTask`(达到系列)。本文只解决一件事:**对外叫什么**。
> 结论先给:**主品牌「达道」**,个人向备选「一件事」。
> 域名与商标的实测状态写在 §3,可用性是**当场查的**,商标必须你自查。
---
## 1. 命名要满足的四个硬条件
1. **和「达」家族连得上** —— 你已经有达到系列,新品牌不该是孤立的一座岛。
2. **能同时装下三件事** —— 任务、工单、知识。只像"待办清单"的名字,天花板就锁死在 todo 工具。
3. **口播一次能记住** —— 客户在电话里说一遍,对方能拼对。
4. **商标能拿到** —— 通用词("达成""任务通")看着好,注册时全是驳回。
---
## 2. 候选与打分
评分 1–5,越高越好。**"注册可行性"是经验判断,不是查询结果**。
| 候选 | 含义 | 记忆度 | 家族延续 | 气质匹配 | 注册可行性 | 口播 | 合计 |
|---|---|---|---|---|---|---|---|
| **达道** | 通达之道;事的路径 | 4 | 5 | 5 | 4 | 4 | **22** |
| **达岸** | 抵达、上岸、交付 | 4 | 5 | 4 | 4 | 4 | 21 |
| **一件事** | 三件事其实是一件事 | 5 | 2 | 5 | 3 | 5 | 20 |
| **达阵** | 橄榄球 touchdown=完成 | 5 | 5 | 2 | 4 | 3 | 19 |
| 达观 | 洞察、看得开 | 4 | 5 | 3 | 1 | 4 | 17 |
| 接得住 | 协同感(别人接住你的事) | 4 | 1 | 3 | 4 | 5 | 17 |
| 达务 | 事务通达 | 2 | 5 | 3 | 4 | 2 | 16 |
| 事有终 | 有始有终 | 3 | 1 | 4 | 4 | 2 | 14 |
### 推荐:达道(Dà Dào)
**为什么是它:**
- **一句话讲得清**:任务是起点,知识是终点,中间是工单 —— 这就是一条"道"。名字本身把产品哲学说完了,不需要额外解释"我们不只是 todo"。
- **和「达到」同源**:达到了,是结果;达道,是路径。系列感成立,且比"达到 XX"更有分量。
- **气质对得上买家**:给团队卖的协同工具,名字要稳。达道偏沉、偏专业,签合同时不掉价。
- **延展性好**:`达道 · 个人版`、`达道 · 团队版`、`达道云` 都能读顺;英文 `Dadao` 也好念。
**要接受的两个小瑕疵:**
- "达道"在佛道语境里也有"悟道"的意思,中文母语者可能有一丝联想 —— 但不足以干扰,反而添了点"想通了"的味道。
- 域名难拿(见 §3),得靠 `dadaotask.com` 这类组合域名过渡。
### 备选:一件事
如果日后想把 **个人用户** 单独拆成一条产品线(免费获客那一层),「一件事」是最好的 ToC 名字:
- "一件事,从头到尾。" —— 直接把"任务→工单→知识"三段讲成一句。
- 缺点:太通用,商标难过;也不适合挂在 ToB 合同抬头。
- 定位:可以做**功能名 / slogan**,不做公司主品牌。比如「达道 · 一件事」。
### 明确否掉的
| 名字 | 否掉的原因 |
|---|---|
| 达务 | 谐音"大雾""打无",口播致命 |
| 达索 | 和达索系统(Dassault)撞,法务风险 |
| 万事达 | Mastercard 中文名,绝对不能用 |
| 达摩 | 宗教人物,商用不敬 |
| 达令 | darling 谐音,气质错位 |
| 任务通 / 协同宝 | 通用词,商标基本无望,且天花板锁死在单点功能 |
---
## 3. 域名现状(2026-10-28 实测)
用 DNS 解析判断是否已被占用(能解析出 A 记录=已有人用):
| 域名 | 状态 | 说明 |
|---|---|---|
| `dadaotask.com` | **未解析,建议立即注册** | 和工程名完全一致,主域名就用它 |
| `dadao.com` | 已被占用(118.193.15.164) | — |
| `dadao.cn` | 已被占用(211.149.239.226) | — |
| `dadaodao.com` | 已被占用(104.21.4.10,Cloudflare) | 说明"达道"这个读音有人在做站 |
| `dadaoyun.com` | 已被占用(34.80.111.222) | "达道云"被占了 |
| `yijianshi.com` | 已被占用 | 备选名的主域名也没了 |
**建议趁早占的四个**:`dadaotask.com`(主)、`dadaotask.cn`(国内备案用)、`dadaotask.app`(安卓/移动落地页)、`dadao.team` 或 `dadao.work`(如果还空着,做品牌短链)。
> 注意:DNS 不解析 ≠ 一定可注册(可能被注册了但没配解析)。下单前在注册商页面再确认一次。
---
## 4. 商标(必须你自查,我无法联网)
- **建议类别**:第 9 类(计算机软件)、第 42 类(SaaS、软件设计与开发);若日后做企业服务打包,再加第 35 类。
- **建议申请形态**:「达道」文字商标 +「达道/Dadao」组合;图形 logo 单独再报一件。
- **自查入口**:中国商标网(sbj.cnipa.gov.cn)→ 商标查询 → 输入"达道",看第 9/42 类是否已被占。**这一步不做就签合同、印物料,风险自负。**
- 已知有公司名含"达道"(域名被占也印证了这一点),**第 9/42 类很可能是争夺点**,所以我把"注册可行性"只打了 4 分。
---
## 5. 一句话定稿(可直接用于官网/BP)
**达道 Dadao** —— 从任务到知识,一条道走到底。
副标:任务、工单、知识库,一个系统,一条流。
个人向 slogan:**一件事,从头到尾。**
---
## 6. 待你决策
- [ ] 主品牌用「达道」还是「达岸」?(我推荐达道,达岸更轻但 ToB 气场弱)
- [ ] 是否现在就注册 `dadaotask.com` 等四个域名?
- [ ] 是否要我按「达道」把 README / PRODUCT-PLAN 里的占位名全部替换掉?
+493
View File
@@ -0,0 +1,493 @@
# DadaoTask 产品与技术方案
> 版本 **v1.0** · 2026-10-28 · 状态:已确认关键技术决策,可进入 M0
> v0.1 → v1.0 的变化:单租户改为 **SaaS 多租户**;删除离线同步;LLM 明确为 **OpenAI 兼容网关 + BYOK**;新增微信/飞书集成层与合规章节。
---
## 1. 一页纸结论
**定位**:一个"个人工作流"内核 + 按需外扩的协作 + 自动沉淀的知识库,三者由 LLM 贯通。**SaaS 形态,个人版免费起步,团队版按席位收费。**
**核心判断**:任务、工单、知识库不是三个并列模块,而是**同一件事的三个阶段**。拆成三个独立产品(滴答清单/禅道/Notion)的代价是重复输入、信息断层、知识库沦为第二个垃圾场。
```
想法 ──▶ 任务 ──(需要别人)──▶ 工单 ──(验收通过)──▶ 知识卡
│ │ │ │
└────────┴────────────────────┴─────────────────────┘
LLM 在每一段之间自动搬运、结构化、建立索引
```
**三条不可妥协**:
| 原则 | 含义 | 反面 |
|---|---|---|
| 一条流 | 任务升级工单不搬数据,时间线不断裂,知识卡可回链源头 | 工单从任务"复制"出去,两边各改各的 |
| 闭环沉淀 | 知识由已完成工作自动生成草稿,人只做确认/丢弃 | 让人下班后回忆今天学了什么再手敲 |
| LLM 只出草稿 | AI 写入一律 `suggestion` 待审,人点确认才落库 | AI 自动建 200 条任务,用户从此关闭该功能 |
**工期**:2 人约 14 周;第 10 周末可对外测(有任务+工单+知识+AI 的完整闭环)。
---
## 2. 已确认的决策(决策记录)
| # | 决策 | 选择 | 对方案的影响 |
|---|---|---|---|
| 1 | 商业模式 | **SaaS,未来对外卖** | 多租户、注册引导、套餐计量、计费钩子必须进 M0;数据隔离是最高优先级风险 |
| 2 | 离线能力 | **不需要,在线优先** | 删掉 outbox/冲突合并/CRDT,省约 4 周;安卓变成"在线客户端 + 只读缓存" |
| 3 | LLM 接入 | **标准 OpenAI 兼容接口**;系统自带 DeepSeek,用户可配 Ollama 等 | 引入 LLM 网关:provider 注册、密钥加密、能力探测、按租户路由与计量 |
| 4 | 后端 | **FastAPI** | 直接支持 SSE 流式、async、OpenAPI 自动出两端 SDK |
| 5 | 集成 | **微信 + 飞书** | 新增集成层(SSO / 通知 / Bot 三平面);需先明确"微信"= 企业微信还是公众号(见 §10.0) |
| 6 | 历史数据 | **无** | 知识库冷启动要靠"闭环沉淀 + 示例内容 + IM 聊天抽取",不能指望导入 |
---
## 3. 服务谁(SaaS 下的定位)
不是"更好的 Jira",也不是"更全的 Notion"。定位:
> **给"一个人干很多事、偶尔要拉人协作"的角色用。** 先服务单个专业用户(免费),当他的协作半径变大,自然升级到团队版。
| 阶段 | 用户 | 付费理由 |
|---|---|---|
| 免费 | 独立开发者、技术负责人、一人公司 | 任务 + 知识 + 少量 AI 额度 |
| Pro | 重度个人用户 | AI 额度、语义检索、画像、周报 |
| Team | 3–50 人小团队 | 席位、工单 SLA、权限、飞书/企微集成、审计 |
**与现有产品的边界**(写清楚是为了不被打):
| 产品 | 我们不做 | 我们做它做不了的 |
|---|---|---|
| 滴答 / Todoist | 极致 GTD、习惯打卡 | 任务能升级成有验收的工单并沉淀知识 |
| 禅道 / Jira | 大型敏捷、sprint、缺陷全家桶 | 轻到一个人就能用,不需要流程专家 |
| Notion / 语雀 | 块编辑器、Wiki 治理 | 知识从工作流里自动长出来 |
| 飞书 / 企微 | IM 本体 | 把聊天里的"事"沉淀成可追踪对象(我们做它的插件) |
---
## 4. 九个核心设计决策
### D1 · 统一实体 `item`,任务与工单同表
`item` 存共有字段,`type ∈ {task, ticket, note}`;工单专属字段放 1:1 的 `ticket_ext`。
**理由**:两者 80% 字段重合;分表会导致升级搬数据、时间线断裂、搜索 UNION、知识来源挂两套外键。**放弃**:全单表 + JSONB(SLA 计算要写 JSON 路径,约束退化)。
### D2 · 一切皆事件:`activity` 是唯一真相源
所有变更写 `activity`(who/what/changed_fields)。一张表同时喂四个消费者:时间线、审计、知识沉淀素材、LLM 上下文。
### D3 · 知识是工作流的产物
`knowledge_doc` 独立成表(有审核/版本/过期生命周期),但**必须能**带 `source_item_id`。入口三个:工单关闭自动生成、手动随手记、IM 消息抽取。
**理由**:知识库失败从来不是功能少,是没人写。
### D4 · 人的画像 `person_profile`
由 activity/item/knowledge 聚合,LLM 定期生成结构化事实(负责领域、近期事项、待跟进),带生成时间与"过期重算"。
只描述**工作事实**,不做性格或绩效评价 —— 产品伦理,也避免用户反感。
### D5 · LLM 写入必须过审:`suggestion` 表
AI 一切结构性写入(建任务、改状态、打标签、合并知识)都落 `suggestion(status=pending)`,人接受/修改/忽略后才 apply。
**理由**:AI 直接写库是所有 AI 产品翻车的头号原因;审批流同时把 AI 从"黑箱"变成"助理"。
### D6 · 多租户:共享库 + `workspace_id` + PostgreSQL RLS
**这是 v1.0 最重要的变更。** 每个租户表都带 `workspace_id`,靠 RLS 做第二道防线(应用层照常过滤,RLS 兜底漏写条件的 bug)。
**放弃**:schema-per-tenant(迁移要跑 N 次、连接池难管)、DB-per-tenant(成本高)。
**逃生口**:大客户私有化 = 同一套代码换 DSN,即"cell 模式",不为它改代码。
**铁律**:多租户必须第一天做对,事后补是灾难 —— 一条漏写 `workspace_id` 的查询就是一次数据泄露事故。
### D7 · 在线优先,不做离线同步
写操作走乐观 UI + 幂等键重试;安卓用 Room 仅作**只读缓存**(不是真相源)。
**理由**:已确认不需要离线。省掉 outbox、冲突合并、墓碑、单调游标这 4 周工作量。
### D8 · 检索只用 Postgres
`pg_trgm` + 中文分词(`zhparser` / `pg_bigm`)+ `pgvector` HNSW。
**多租户细节**:向量检索必须带 `workspace_id` 过滤;用 pgvector ≥ 0.8 的 iterative scan,或按租户建**部分索引**,避免"跨租户召回再过滤"把 top-k 稀释。
### D9 · LLM 网关:OpenAI 兼容 + BYOK + 平台兜底
一个 `LLMProvider` 抽象层兼容所有 OpenAI 协议实现(DeepSeek / OpenAI / Ollama / 通义 / 智谱 / 月之暗面)。
平台自带 DeepSeek 按套餐给额度;用户可自带 key 与 base_url。密钥加密存储、永不回显、永不进日志。
### D10 · 集成层三平面
SSO(扫码登录)/ 通知(消息卡片)/ Bot(在 IM 里建任务)。三者各自是适配器,不耦合进领域逻辑;出站通知落库后由异步分发,IM 挂了不阻塞主流程。
---
## 5. 领域模型
### 5.1 实体关系
```mermaid
erDiagram
WORKSPACE ||--o{ MEMBER : has
USER ||--o{ MEMBER : joins
WORKSPACE ||--o| SUBSCRIPTION : "billed by"
WORKSPACE ||--o{ LLM_PROVIDER : configures
WORKSPACE ||--o{ INTEGRATION : connects
USER ||--o{ ITEM : owns
ITEM ||--o| TICKET_EXT : extends
ITEM ||--o{ ITEM_LINK : links
ITEM ||--o{ COMMENT : has
ITEM ||--o{ ACTIVITY : logs
ITEM ||--o| KNOWLEDGE_DOC : "distills to"
KNOWLEDGE_DOC ||--o{ CHUNK : "split into"
USER ||--o| PERSON_PROFILE : "profiled by"
SUGGESTION }o--|| ITEM : "applies to"
THREAD ||--o{ MESSAGE : contains
WORKSPACE ||--o{ USAGE_EVENT : meters
```
### 5.2 表清单(22 张;MVP 需 14 张)
| 表 | 作用 | 关键字段 | MVP |
|---|---|---|---|
| `workspace` | 租户 | id, name, slug, plan, status | ✅ |
| `user` | 账号(跨租户唯一) | id, phone, email, name, avatar, password_hash | ✅ |
| `member` | 成员与角色 | ws_id, user_id, role, status, invited_by | ✅ |
| `invitation` | 邀请 | ws_id, token, role, expires_at | ✅ |
| `item` | 统一实体 | id, ws_id, type, title, body_md, status, priority, owner_id, due_at, done_at, visibility, ext, version, deleted_at | ✅ |
| `item_assignee` | 多负责人 | item_id, user_id, role | ✅ |
| `ticket_ext` | 工单专属 | item_id, requester_id, sla_due_at, accepted_at, resolved_at, verified_at, verify_result | ✅ |
| `item_link` | 关系 | from_id, to_id, kind | ✅ |
| `comment` | 评论/内部备注 | item_id, author_id, body_md, internal | ✅ |
| `activity` | 事件流 | item_id, ws_id, actor_id, verb, changed_fields, at | ✅ |
| `tag` / `item_tag` | 标签 | — | ✅ |
| `knowledge_doc` | 知识卡 | ws_id, title, summary, body_md, source_item_id, status, review_at, version | ✅ |
| `suggestion` | AI 待审草稿 | ws_id, kind, payload, target_id, status, model, confidence | ✅ |
| `subscription` / `usage_event` | 套餐与计量 | plan, seats, period, kind, qty | ✅ |
| `chunk` | 切片 + vector(1024) | doc_id, seq, text, tsvector, embedding | V1 |
| `attachment` | 附件 | owner_id, oss_key, mime, size | V1 |
| `llm_provider` | 租户 LLM 配置 | ws_id, kind, base_url, model, key_enc, status | V1 |
| `integration` | 集成配置 | ws_id, provider, plane, config_enc, status | V1 |
| `thread` / `message` | AI 会话 | anchor_item_id, role, content, citations, tokens | V1 |
| `person_profile` | 人物画像 | user_id, ws_id, summary_md, facts, generated_at | V2 |
| `notification` | 出站消息队列 | ws_id, channel, payload, status, retry | V1 |
| `audit_log` | 管理操作审计 | ws_id, actor, action, target, ip | V1 |
### 5.3 状态机
**任务**:`inbox → todo → doing → done`,旁挂 `blocked`、`canceled`
**工单**:`open → accepted → in_progress → pending_verify → done`,旁挂 `reassigned`、`canceled`
**知识**:`draft → in_review → published → archived`
> **一条流的关键机关**:任务升级为工单后自动进 `blocked`;工单验收通过自动回流 `doing/done`。
> 用户不需要在两个地方各改一次状态 —— 比任何 AI 功能都更能说明"这是一个产品"。
---
## 6. 功能地图
### 6.1 MVP(第 1–10 周,目标:自己天天用 + 可对外测)
**任务**:一句话快速录入(全局快捷键/分享面板)、收件箱/今天/本周/全部四视图、优先级/截止/标签/子任务、看板拖拽、移动端列表。
**工单**:任务一键升级(选人+期望时间+说明)、我发起的/待我处理/我参与的、状态流转、评论与内部备注、验收通过或打回、关闭时触发沉淀。
**知识**:手动新建、从工单生成四段式草稿(问题/根因/处理/验证)、全文搜索、来源回链。
**AI 三场景**:NL → 结构化任务/工单;工单关闭 → 知识草稿;基于知识库问答(必须带引用,无来源就说不知道)。
**SaaS 必备**:手机号验证码注册、创建团队、邀请成员、四角色权限、免费额度与用量页、数据导出、管理后台(看租户/用量/封禁)。
**基础**:站内通知 + 邮件;安卓端(在线)。
### 6.2 V1(第 11–16 周)
- 语义检索(pgvector)+ 混合排序
- 对话式 Agent(对话里查任务/改状态,仍走审批)
- 人物画像、自动周报
- 飞书/企微:扫码登录、消息卡片通知、Bot 建任务
- 附件 + 图片 OCR 入知识
- 套餐升级与在线支付(支付宝/微信)
- 审计日志、知识卡过期提醒
### 6.3 V2
- 知识图谱与可视化
- 相似去重与自动归档(>0.92 提示合并)
- 自定义工单字段与流程模板
- 开放 API / Webhook
- 小程序端(承接微信生态的轻量入口)
- 安卓桌面小组件
### 6.4 明确不做
甘特图与资源负载 · BPMN 审批引擎 · 实时协同编辑 · 自建 IM · 离线模式 · 细粒度字段级权限 · 对外开放插件市场(至少两年内)
---
## 7. LLM 网关设计(v1.0 重写)
### 7.1 Provider 模型
```python
class LLMProvider(BaseModel):
ws_id: str | None # None = 平台内置
kind: Literal["openai_compatible"]
base_url: str # https://api.deepseek.com/v1 ; http://ollama:11434/v1
model: str # deepseek-chat / qwen2.5:14b ...
embedding_model: str | None
key_enc: bytes | None # AES-256-GCM 密文,永不回显
status: Literal["ok", "invalid", "unreachable"]
capabilities: dict # 探测结果:stream / tools / json_mode / vision
```
- **预置模板**:DeepSeek(平台默认,给额度)、OpenAI、Ollama(本地/内网)、通义千问、智谱、月之暗面、硅基流动 —— 全部是 OpenAI 兼容协议,只是 `base_url` 与模型名不同。
- **能力探测**:保存时调一次 `GET /models` + 一次 8 token 的 chat,标记 `capabilities`;流式/tools 不支持就自动降级(例如不支持的走两段式:先出 JSON 再渲染)。
- **密钥安全**:AES-256-GCM,主密钥来自环境变量或 KMS;前端只看到 `sk-****abcd`;日志、追踪、错误上报统一脱敏中间件。
- **分层覆盖**:租户默认 → 功能级覆盖(打标签用便宜模型,问答用强模型)→ 全局兜底(平台 key)。
- **网络可达性**:Ollama 场景下用户填的是自己内网地址,SaaS 后端根本连不上 —— **必须支持"客户端代理调用"模式**:浏览器/安卓直接请求用户的 Ollama,结果回传后端只落库不入模型链路。这是私有化用户最常见的坑,M4 就要处理。
### 7.2 六个能力
| 能力 | 输入 | 输出 | 边界 |
|---|---|---|---|
| A 结构化录入 | 一句话 | item 草稿 | 只建议不建 |
| B 闭环沉淀 | 工单 activity 流 | 四段式知识卡草稿 | 必须回链 source_item_id |
| C 自动索引 | 知识正文 | 标签/实体/相似卡 | 标签库受控,不许乱造 |
| D 问答 | 问题 + 检索 | 带引用回答 | 无来源就说不知道 |
| E 人物画像 | 某人 activity | 5–8 条事实 | 只写工作事实 |
| F 主动提醒 | 全局状态 | 风险项 | 每日最多 1 条 |
### 7.3 上下文组装
```
[系统] 角色 + 输出格式约束 + 工作区一句话摘要
[对象] 对话对象画像(≤5 条,各 1 行)
[事实] 相关 item 摘要(top 8,各 1 行)
[证据] 相关 chunk(top 12,各 ≤200 字,带 [doc#seq] 来源标记)
[历史] 本会话最近 6 轮
[输入] 用户当前话语
```
超预算裁剪顺序:chunk 细节 → item 数量 → 画像条数 → **绝不砍系统规则与来源标记**。
预算:输入 ≤ 8k tokens,输出 ≤ 1.5k。
### 7.4 计量、限流、降级
- 每次调用写 `usage_event`(tokens_in/out, model, latency, ws_id)→ 套餐月度汇总 → 超限只降级不硬断(降级为关键词检索 + 模板回答)。
- 租户级 QPS 与并发上限;平台 key 按租户配额,BYOK 只做滥用防护。
- 缓存:同问题 + 同检索结果哈希 → 命中即回,省 30%+。
- **隐私**:prompt/response 默认不落库;调试开关 7 天自动清除,且界面上明确告知。
- **评测**:20 条黄金用例(含 5 条"应当回答不知道"),改 prompt 必跑。
---
## 8. 多租户、权限与计费(SaaS 核心)
### 8.1 隔离
- **应用层**:所有仓储方法强制要求 `ws_id`(类型层面让它成为必填参数,而不是可选)。
- **数据库层**:`SET LOCAL app.ws_id = $1` 于每个请求事务开头,RLS 策略 `USING (workspace_id = current_setting('app.ws_id')::uuid)`。
- **测试层**:每个资源一套「跨租户越权测试」,断言必须 0 行 / 403。这条测试进 CI,红了不许合并。
- **对象存储**:key 前缀 `ws/{ws_id}/...`,签名 URL 绑定租户。
### 8.2 角色(4 个,不做矩阵)
`owner`(含计费与删除租户)/ `admin`(管人与配置)/ `member`(读写团队内容)/ `guest`(仅被指派的工单)。
实现:`PERMISSIONS` 集中式守卫表(资源 → 动作 → 最低角色),绝不散落 `if`。PosSync 的 `PERMISSION_GUARDS` 写法可直接搬。
### 8.3 套餐与计量
| 套餐 | 席位 | AI 额度 | 关键功能 |
|---|---|---|---|
| Free | 1 | 少量/月 | 任务、工单、知识、基础搜索 |
| Pro | 1 | 中 | 语义检索、画像、周报、更大附件 |
| Team | 3+ | 按席位 | SLA、集成、审计、SLA 提醒、优先支持 |
计量只做三样:AI tokens、存储、席位数。**MVP 不做在线支付**(先手工开通/邀请码),避免把 3 周花在支付对账上;V1 再接支付宝/微信。
### 8.4 注册与引导
手机号验证码 → 建个人工作区 → 3 条示例任务(可一键清空)→ "要不要接入 AI?"(默认平台额度)→ 完成。
**关键指标**:从注册到"完成第一个任务"的时间,目标 < 90 秒。
---
## 9. 技术架构
```mermaid
flowchart TB
subgraph C[客户端]
W["PC Web · React 18 + Vite + Tailwind"]
A["Android · Kotlin + Compose"]
B["飞书 / 企微 Bot"]
end
subgraph G[接入]
N["Nginx · TLS · 限流 · 静态资源"]
end
subgraph S[应用层 · FastAPI]
API["REST + SSE"]
AUTH["认证 · JWT · OAuth"]
SVC["领域服务 item / ticket / knowledge"]
AI["AI 编排 · suggestion 审批 · 上下文组装"]
JOB["异步任务 · arq + Redis"]
INT["集成适配器 · SSO / 通知 / Bot"]
end
subgraph D[数据层]
PG["PostgreSQL 16 · RLS · pgvector"]
R["Redis · 缓存 / 队列 / 限流"]
OSS["MinIO/S3 · 附件"]
end
AI --> GW["LLM 网关"]
GW --> P1["平台 DeepSeek"]
GW --> P2["用户 BYOK"]
GW --> P3["用户 Ollama · 客户端代理"]
W --> N --> API
A --> N
B --> N
API --> SVC --> PG
API --> AI
JOB --> SVC
INT --> B
```
| 层 | 选择 | 理由 |
|---|---|---|
| 后端 | FastAPI + Pydantic v2 | SSE 流式、OpenAPI 出 SDK、async |
| 数据库 | PostgreSQL 16 + pgvector | 关系/全文/向量一库;RLS 直接可用 |
| 队列 | Redis + arq | 轻量 asyncio;Celery 更重 |
| 前端 | React 18 + Vite + Tailwind | 沿用 PosSync 栈,熟 |
| 安卓 | Kotlin + Compose | 沿用已有经验;Flutter 收益不明显 |
| SDK | OpenAPI → TS/Kotlin 生成 | 手写两端必漂移 |
| 部署 | Docker Compose 单机起步 | 早期不上 K8s;按 cell 预留水平扩展 |
| 分发 | 官网 APK + 华为/小米/OPPO/vivo/应用宝 | 国内不上 Google Play |
**代码结构**
```
DadaoTask/
├─ 2.Design/ 设计文档、数据库、部署、事故记录
├─ backend/
│ ├─ app/api/ items, tickets, knowledge, ai, auth, admin, sync(无)
│ ├─ app/domain/ 状态机、权限、SLA、租户上下文
│ ├─ app/infra/ db, redis, oss, llm, integration
│ ├─ app/ai/ context builder, prompts, retriever, suggestion
│ └─ app/jobs/ embedding、周报、画像、通知分发
├─ web/ React
├─ android/ Kotlin Compose
└─ deploy/ compose、nginx、备份
```
**沿用 PosSync 的铁律**(写进 AGENTS.md):改完必须提交并 push;生产不得领先仓库;前端产物只能由仓库 HEAD 构建,部署后校验 build 标记。
---
## 10. 微信与飞书集成
### 10.0 纠正:个人微信**有**官方 Bot 通道
v1.0 初稿这里写错了(原判断"个人微信没有官方机器人接口")。核实后的事实:腾讯已开放 **微信 ClawBot / iLink Bot 协议**(`https://ilinkai.weixin.qq.com`),DSH 的 IM 插件 `@michengai/dsh-im-connect` 微信渠道就是基于它实现,源码里明确写着"腾讯官方产品,非逆向方案"。
已核实的技术事实(来源:`@michengai/dsh-im-connect` 的 `src/channels/weixin.ts`、`src/channels/qr/weixin.ts`、README 支持矩阵):
| 事实 | 内容 | 对产品的影响 |
|---|---|---|
| 登录方式 | `POST /ilink/bot/get_bot_qrcode?bot_type=3` 取二维码 → 轮询 `/ilink/bot/get_qrcode_status` → 拿到 `bot_token` | 用户扫码即绑定,不用填任何密钥,接入摩擦极低 |
| 收消息 | 长轮询(poller),必须持久化 `syncBuf` 游标防丢防重 | 一个绑定 = 一条常驻长轮询连接,这是架构约束 |
| 回消息 | `POST /ilink/bot/sendmessage`,按 `context_token` 回复 | — |
| 媒体 | 图片/语音/文件/视频,CDN AES-128-ECB 加密,单文件上限 50 MB | 可以语音记任务、直接把文件甩进知识库 |
| 会话范围 | **仅单聊**,不支持群 | 团队协作的消息面仍得靠飞书/企微 |
| 账号模型 | **一个微信号一个 poller**,官方建议用专用小号 | 见 10.3 的架构分歧,这是最关键的决策点 |
| 合规 | 官方 iLink,插件声明 "no reverse-engineered personal protocol";使用即同意《微信 ClawBot 功能使用条款》 | 不能用 hook/逆向;条款原文的商用边界需甲方自查(我无法联网检索条款全文) |
### 10.0.1 其他微信通道(仍然有效,按需用)
| 通道 | 能做什么 | 建议 |
|---|---|---|
| **企业微信** | 应用消息、审批、扫码登录、群机器人 | ✅ 团队协作首选 |
| **公众号/服务号** | 模板消息(提醒推送)、扫码关注绑定、H5 入口 | ✅ 个人用户的推送通道 |
| **小程序** | 轻量移动入口,可替代部分安卓场景 | 后续,若安卓分发成本过高再做 |
| **个人微信 iLink** | 扫码绑定、单聊收发、语音/文件 | ✅ 个人用户入口(见 10.3) |
### 10.1 三个平面
| 平面 | 飞书 | 企微 | MVP |
|---|---|---|---|
| SSO | 网页扫码/免登 | 网页授权扫码 | V1 |
| 通知 | 消息卡片(可带"接受/完成"按钮) | 应用消息 / 群机器人 | V1 |
| Bot | 事件订阅 → 建任务/查工单 | 群机器人 → 同上 | V2 |
### 10.2 设计要点
- `integration` 表存 provider + plane + 加密配置,每个租户可挂多个。
- 出站走 `notification` 队列 + 重试 + 死信,IM 故障不阻塞业务。
- 入站必须验签(飞书 encrypt key / 企微 token+sha1),并且**幂等**(同一 event_id 只处理一次)。
- Bot 收到的自然语言 → 走 §7 能力 A → 返回**卡片待确认**,用户点确认才建。避免"群里一句话建了十条任务"。
- 双向价值:群里的讨论也能反向抽成知识卡(V2 的差异化点,别人没有)。
### 10.3 个人微信在 SaaS 里的三种接法(要你拍板)
约束源自 10.0 表格最后两行:**单聊 only + 一个微信号一个 poller + 建议专用小号**。这直接推出三种互斥的架构:
| 接法 | 怎么做 | 优点 | 代价 |
|---|---|---|---|
| **A 用户自带微信号** | 每个用户自己扫码,把一个小号变成"我的助理",主号私聊它记任务 | 风险分散到用户;我们不用养号;天然多租户隔离 | 用户得有第二个微信号 → 对普通用户是转化率杀手,只适合重度用户 |
| **B 平台统一号** | 我们运营一个微信助理号,所有用户加它,按白名单识别身份 | 零门槛,注册即用,是真正能拉新的入口 | 一条 poller 承载全平台会话(要压测);好友数上限;**一个号被封 = 全员失联**;条款是否允许商用未知 |
| **C 本地 bridge** | 用户在自己电脑上跑轻量转发程序(DSH 插件就是这种形态),微信连接留在用户侧,云端只收结构化数据 | 最稳、最合规、服务器最省 | 要装东西;电脑关机就断;不适合纯手机用户 |
**我的建议**:MVP 走 **A**,但设计成"高级开关、默认关闭"——先把数据模型和 `integration` 表做对,接不接微信都不影响主流程。B 等条款核实 + 压测数据出来再评估,不要现在押注。C 等桌面端有了再说。
⚠️ **但选型前必须做 0.5 天 spike**,验证三件必须实测才知道的事(拍脑袋会返工):
1. **谁是 bot** —— 扫码的那个微信号自己变成 bot,还是被授权成某个服务实体?(决定"用户要不要备小号")
2. **能否多用户共用** —— 不同微信号私聊同一个 bot,是否都能收到、且会话互不串?(决定 B 是否可行)
3. **风控边界** —— 连续/并发发消息的限流阈值、长轮询断线重连行为、`bot_token` 有效期与失效时机。
结论写进 `2.Design/SPIKE-WECHAT-ILINK.md`,再回来定 10.3 的最终选型。
---
## 11. 安全与合规(SaaS 必答)
| 事项 | 现在要做 | 说明 |
|---|---|---|
| 个人信息保护法 | 隐私政策、最小采集、导出/删除接口、注销 | 卖给企业客户时会被问到 |
| 数据出境 | 默认用境内模型(DeepSeek 等);用境外模型时明确告知并取得同意 | 用户 BYOK 到 OpenAI 属于用户自选,需二次确认弹窗 |
| ICP 备案 | 域名必须备案;涉及在线交易可能需 EDI | 提前办,别卡上线 |
| 等保 | 面向企业客户大概率要求等保二级 | 初期至少做到:日志留存 6 个月、堡垒机、备份、漏洞扫描 |
| 密钥 | 加密入库、不进日志、支持轮换 | 一条脱敏中间件统一处理 |
| 越权 | RLS + 跨租户测试进 CI | 最高优先级 |
| 备份 | 每日全量 + WAL 归档,恢复演练每季度 | 复用现有备份脚本 |
---
## 12. 里程碑(2 人)
| 阶段 | 周 | 交付 | 验收 |
|---|---|---|---|
| M0 骨架 | 1–2 | 多租户认证、RLS、迁移、CI、Compose、部署 | 两个租户互不可见(有测试证明) |
| M1 任务 | 3–4 | 四视图、看板、标签、搜索 | 自己连续 3 天全量使用不别扭 |
| M2 工单 | 5–6 | 升级、状态机、评论、验收、blocked 联动 | 两人真实跑通一个工单闭环 |
| M3 知识 | 7–8 | 知识卡、来源回链、工单草稿、全文搜索 | 关一个工单能顺手产出一张有用卡 |
| M4 AI | 9–10 | 三场景、suggestion 审批、LLM 网关、BYOK | 黄金用例 ≥ 80%;换模型不改业务代码 |
| **对外测** | **10** | 邀请制内测,10–20 个真实用户 | 周留存与"第一个任务完成率"有数 |
| M5 安卓 | 11–12 | Compose 主界面、推送、在线缓存 | 弱网可用、崩溃率 < 1% |
| M6 集成 | 13 | 飞书/企微登录与通知卡片 | 群里能收到并一键处理工单 |
| M7 SaaS | 14 | 套餐计量、用量页、管理后台、导出 | 能手工开单、看用量、封禁滥用 |
| M8 打磨 | 15 | 性能、合规材料、文档、商店上架 | 可公开注册 |
> 提前到第 10 周对外测,是为了让真实用户决定 M5 之后做什么,而不是我猜。
---
## 13. 风险与对策
| 风险 | 概率 | 影响 | 对策 |
|---|---|---|---|
| 多租户越权泄露 | 中 | 致命 | 仓储层强制 ws_id + RLS + 跨租户测试进 CI |
| 范围蔓延(三产品叠加) | 高 | 致命 | §6.4 不做清单当合同;加功能必砍功能 |
| 免费用户刷 AI 额度 | 高 | 中 | 硬配额 + 每日衰减 + 异常检测(同 IP 多账号) |
| LLM 幻觉 | 中 | 高 | 强制引用、无来源不答、黄金用例回归 |
| Ollama 内网不可达 | 高 | 中 | 客户端代理调用模式(§7.1) |
| 知识冷启动无人写 | 中 | 高 | 闭环沉淀 + 示例内容 + IM 抽取;首批种子由我们代写 20 张 |
| 一个人维护三端 | 中 | 中 | OpenAPI 生成 SDK;安卓只做读写与推送 |
| 三个月后自己不用了 | 中 | 致命 | M1 起强制 dogfooding,每周记一条"用得不爽"并优先修 |
| 备案/等保卡上线 | 中 | 高 | 现在就去办 ICP;等保按客户要求提前准备 |
---
## 14. 下一步
1. **M0 骨架**:仓库结构、Compose(PG16/Redis/MinIO)、多租户认证、RLS、迁移、CI、部署脚本。
2. **`2.Design/DATABASE.md`**:22 张表落成 DDL + RLS 策略 + 索引。
3. **`2.Design/API.md`** + OpenAPI 导出脚本:三端并行开发的契约。
4. 确认三件小事:产品名/域名、端口分配(避开 PosSync 的 5888/5176)、Gitea 仓库创建。
> 顺序建议:**先让"任务"这条腿站住,再谈 AI**。反着做的项目,两周后演示很炫,用户却没有真实数据可用。
+166
View File
@@ -0,0 +1,166 @@
# M0–M1 实施清单
> 来源:`2.Design/PRODUCT-PLAN.md` v1.0 第 12 节
> 粒度:每项 0.3–1.5 人日,2 人并行 ≈ 2 周一个里程碑
> 状态标记:`[ ]` 未开始 `[~]` 进行中 `[x]` 完成
---
## M0 · 骨架(第 1–2 周,10 人日)
**里程碑目标**:一个能注册、能建团队、能证明"两个租户互相看不见"的空壳,部署在服务器上,CI 全绿。
### 0.1 仓库与约定(0.5d)
- [ ] 目录结构:`backend/ web/ android/ deploy/ 2.Design/`
- [ ] `AGENTS.md`:提交纪律(改完必 push)、前端产物只能由 HEAD 构建、文档落点
- [ ] `.gitignore` / `.editorconfig` / `README.md`
- [ ] 分支策略:`main` 受保护,功能走 `feat/*`;提交信息 `feat(module): 中文描述`
- **验收**:Gitea 上能看到仓库,PR 模板可用
### 0.2 本地环境(1d)
- [ ] `deploy/docker-compose.dev.yml`:postgres:16(含 pgvector)+ redis:7 + minio
- [ ] `postgres` 初始化脚本:`CREATE EXTENSION vector, pg_trgm, pgcrypto`
- [ ] `.env.example` 全量注释
- **验收**:`docker compose up -d` 后 `psql` 能连、扩展已装
### 0.3 配置与日志(0.5d)
- [ ] `pydantic-settings` 分层配置(dev/prod),启动即校验必填项
- [ ] 结构化日志(JSON)+ `request_id` 贯穿 + 敏感字段脱敏中间件(`api_key`/`token`/`password`)
- **验收**:一个请求在日志里能用 request_id 串起来,密钥不会出现在日志里
### 0.4 数据库基座(1d)
- [ ] `alembic init`,autogenerate 接入,`env.py` 读应用配置
- [ ] 基础表迁移:`workspace` `user` `member` `invitation` `audit_log`
- [ ] 通用列约定:`id uuid`、`created_at`、`updated_at`、`version`、`deleted_at`
- **验收**:空库 `alembic upgrade head` 一次成功;`downgrade` 可回滚
### 0.5 租户上下文 + RLS(1.5d)★ 核心
- [ ] 请求依赖:解析 JWT → 校验成员身份 → `SET LOCAL app.ws_id`
- [ ] 全部租户表启用 RLS,策略 `workspace_id = current_setting('app.ws_id')::uuid`
- [ ] 仓储基类强制 `ws_id` 为必填构造参数(不给默认值)
- [ ] 测试:租户 A 的 token 读租户 B 的资源 → 0 行 / 403
- **验收**:`pytest -k tenant_isolation` 全绿;故意删掉一条 where 条件时测试必须变红(用变异测试验证一次)
### 0.6 认证(1.5d)
- [ ] 手机号 + 验证码注册/登录(开发环境固定码 `123456`,短信通道留接口)
- [ ] 邮箱 + 密码(argon2)作为备选
- [ ] JWT:access 15min / refresh 30d 轮换,refresh 落库可撤销
- [ ] 登录限流(IP + 账号维度)
- **验收**:注册 → 登录 → 刷新 → 登出 全链路可用;错误码清晰(不是 500)
### 0.7 权限守卫(0.5d)
- [ ] `PERMISSIONS` 字典:`(资源, 动作) → 最低角色`,FastAPI 依赖注入统一执行
- [ ] 四个角色:owner / admin / member / guest
- **验收**:guest 调管理接口 403;member 调 admin 接口 403
### 0.8 接口规范(0.5d)
- [ ] 统一响应体 `{code, message, data, request_id}`
- [ ] 错误码表(`2.Design/API.md` 里维护)
- [ ] 写接口支持 `Idempotency-Key`(Redis 落 24h)
- [ ] 分页规范:cursor 优先,`limit` 上限 100
### 0.9 健康与版本(0.3d)
- [ ] `/health`(DB/Redis 探活)、`/version`(返回 commit + 构建时间)
- **验收**:部署后 `curl /version` 与 `git rev-parse --short HEAD` 一致
### 0.10 CI(0.7d)
- [ ] `ruff` + `mypy`(宽松起步)+ `pytest`
- [ ] 迁移检查:`alembic upgrade head` 后再 `alembic check` 无差异
- [ ] 镜像构建 + 推送到私有 registry(或直接在服务器构建)
- **验收**:坏代码推上去 CI 变红,前端产物不入库
### 0.11 部署(1d)
- [ ] `deploy/docker-compose.prod.yml` + nginx 反代 + TLS
- [ ] 端口分配登记到 `2.Design/DEPLOY.md`(避开 PosSync 的 5888 / 5176)
- [ ] 备份脚本接入现有每日备份(pg_dump + 保留 14 天)
- **验收**:从零一台机器 30 分钟内起完;重启后数据在;备份可恢复到临时库
### 0.12 三端契约(0.5d)
- [ ] 导出 `openapi.json`,生成 TS 客户端与 Kotlin 客户端
- [ ] `make sdk` 一键重生成
- **验收**:改一个后端字段,两端编译报错(说明契约真的在生效)
### 0.13 客户端壳(1d)
- [ ] Web:Vite 起项目、路由、登录页、空白任务页、请求封装(含 refresh 重试)
- [ ] Android:Compose 工程、登录页、空列表页、Retrofit+OkHttp 封装
- **验收**:两端都能真实登录并拿到空列表
### 0.14 微信 iLink 通道 spike(0.5d)★ 决策前置
> 不阻塞 M0 交付,但必须在写 `integration` 表之前做完,否则接法选错要返工。详见 `PRODUCT-PLAN.md` §10.3。
- [ ] 用 DSH 插件(`@michengai/dsh-im-connect`)实际扫码绑定一个微信号,观察 bot 身份归属
- [ ] 用第二个微信号私聊该 bot,确认多用户共用一号时消息是否互不串
- [ ] 压一下:连续 20 条消息的延迟与是否触发风控;断网 5 分钟后 `syncBuf` 能否续上
- [ ] 记录 `bot_token` 失效表现(多久失效、失效后是报错还是静默)
- **产出**:`2.Design/SPIKE-WECHAT-ILINK.md`,明确回答"用户自带号"还是"平台统一号"
- **验收**:文档里三个问题都有实测结论,不是推测
---
## M1 · 任务域(第 3–4 周,10 人日)
**里程碑目标**:自己开始每天用它记待办,连续 3 天不别扭。
### 1.1 数据(1d)
- [ ] `item` `item_assignee` `tag` `item_tag` `comment` `activity` 迁移
- [ ] 索引:`(ws_id, status, due_at)`、`(ws_id, owner_id, status)`、`deleted_at` 部分索引
- [ ] 全文索引:`zhparser` 或 `pg_bigm` 建在 `title + body_md`
### 1.2 领域(1.5d)
- [ ] 状态机 `inbox→todo→doing→done`(+ blocked/canceled),非法流转拒绝并给提示
- [ ] 所有变更写 `activity`(含 `changed_fields` 增量)
- [ ] `version` 乐观锁,冲突返回 409 带当前版本
### 1.3 API(1.5d)
- [ ] 增删改查、批量状态变更、排序(手动 sort_order)
- [ ] 视图查询:inbox / today / week / all,支持标签与关键词过滤
- [ ] 搜索接口(标题 + 正文,返回高亮片段)
### 1.4 Web(3d)
- [ ] 四视图 + 看板拖拽(列 = 状态)
- [ ] 详情侧栏:优先级/截止/标签/备注/子任务(一层)/评论
- [ ] 全局快速录入(`Ctrl+K` 或顶部输入框,支持 `#标签` `!优先级` `@人` 语法)
- [ ] 键盘流:`j/k` 上下、`Enter` 完成、`e` 编辑
### 1.5 安卓(1.5d)
- [ ] 列表 + 详情 + 勾选完成
- [ ] 下拉刷新、只读缓存(Room,仅离线展示,改动必须联网)
### 1.6 通知与提醒(0.8d)
- [ ] 站内通知表与未读角标
- [ ] 每日摘要邮件(可关)
### 1.7 测试与验收(0.7d)
- [ ] 每个接口一条 happy path + 一条越权用例
- [ ] 前端关键交互 e2e(Playwright 起 3 条:登录、建任务、拖动状态)
### M1 完成定义(DOD)
- [ ] 连续 3 天所有待办都记在里面,没有回到微信/备忘录
- [ ] 冷启动到"建完第一个任务"< 90 秒(新账号实测)
- [ ] 1000 条任务下四视图首屏 < 300ms
- [ ] 跨租户测试、越权测试全绿
---
## 关键验收用例(M0–M1 必须通过)
| # | 用例 | 期望 |
|---|---|---|
| 1 | 租户 A 用户请求租户 B 的 item id | 404(不是 403,避免泄露存在性) |
| 2 | 同一手机号注册两次 | 第二次提示已存在并引导登录 |
| 3 | 被移出团队的成员拿旧 token 请求 | 401,且 refresh 失效 |
| 4 | 并发两次提交同一任务的不同状态 | 一次成功,一次 409 并可重试 |
| 5 | 网络中断后重新提交 | 幂等键保证不产生两条 |
| 6 | 删除任务后全局搜索 | 搜不到,且回收站可恢复 |
| 7 | 停掉 Redis | `/health` 变 503,业务接口给明确错误而非 500 |
| 8 | 部署后 `/version` | commit 与仓库 HEAD 一致 |
---
## 下一步排期建议
```
第1周 0.1–0.7(另一个人并行做 0.13 客户端壳)
第2周 0.8–0.12(另一个人并行做 Web 登录与请求封装)
第3周 1.1–1.3 + 1.4 开工
第4周 1.4–1.7 + 自己开始 dogfooding
```
+69
View File
@@ -0,0 +1,69 @@
# AGENTS.md — 达道Task 仓库工作约定
> 给所有在本仓库工作的 AI / 人类协作者的强制约定。**改动前先读完此文件。**
## 1. 项目是什么
达道Task:任务管理(todo)→ 工单协同 → 知识库,三件事一条流,LLM 贯通。
Web(React)+ 安卓(Compose)+ 后端(FastAPI),SaaS 多租户形态。
- 方案:[`2.Design/PRODUCT-PLAN.md`](2.Design/PRODUCT-PLAN.md)
- 实施清单:[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
- 命名:[`2.Design/NAMING.md`](2.Design/NAMING.md)
## 2. 铁律(违反即回滚)
1. **改完必须提交并 push**。本地有未推送提交视为未完成。
2. **生产不得领先仓库**。部署的代码必须是仓库 HEAD,不允许"服务器上手动改一下"。
3. **前端产物只能由仓库 HEAD 构建**,不允许本地构建后上传 dist。
4. **部署后必须校验 build 标记**:`curl /version` 返回的 commit 必须等于 `git rev-parse --short HEAD`。
5. **多租户安全不讨价还价**:任何新增租户表,必须同时提交 ① `workspace_id NOT NULL` 列 ② RLS 策略 ③ 跨租户隔离测试。三者缺一,PR 直接拒。
## 3. 技术栈(不得擅自更换)
| 层 | 选型 |
|---|---|
| 后端 | FastAPI + SQLAlchemy 2.0 async + PostgreSQL 16(RLS + pgvector)+ Redis |
| 迁移 | Alembic(autogenerate 后**必须人工复核**,不准盲上) |
| Web | React 18 + TypeScript + Vite + Tailwind |
| 安卓 | Kotlin + Jetpack Compose |
| LLM | OpenAI 兼容网关;平台兜底 DeepSeek,用户可配 |
## 4. 目录
```
backend/ FastAPI 服务(app/ 业务,tests/ 测试,alembic/ 迁移)
web/ React 前端
android/ Kotlin 安卓端
deploy/ 部署脚本与 compose
2.Design/ 设计文档(大写连字符命名,如 API.md、DEPLOY.md)
```
## 5. 提交规范
```
feat(task): 支持批量改状态
fix(auth): 刷新令牌未轮换
docs(plan): 补充工单状态机
```
- 一个提交只做一件事;格式化与业务分开提交。
- `main` 受保护,功能走 `feat/*` 分支。
## 6. 本地开发
```bash
# 依赖
cd deploy && docker compose -f docker-compose.dev.yml up -d
cd backend && pip install -r requirements.txt
pytest # 必须全绿才能提交
ruff check . && ruff format --check .
```
## 7. 禁区
- 不准把密钥、token、真实用户手机号写进仓库或测试夹具。
- 不准在日志里打印 `password` / `token` / `api_key`(中间件已做脱敏,别绕开它)。
- 不准跳过租户隔离测试,哪怕"这次只是改文档"。
- 不准直接读写 `.dsh/memory-vault`(记忆库只能通过工具/API 写入)。
+18
View File
@@ -0,0 +1,18 @@
# DadaoTask
任务管理 · 工单协作 · 知识库,三件事一条流,由 LLM 贯通。**SaaS 形态**(个人免费 → 团队按席位)。
- 产品与技术方案(v1.0):[`2.Design/PRODUCT-PLAN.md`](2.Design/PRODUCT-PLAN.md)
- M0–M1 实施清单:[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
- 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md)
## 技术栈
- 后端:FastAPI + PostgreSQL 16(RLS + pgvector)+ Redis(arq)
- PC:React 18 + Vite + Tailwind
- 安卓:Kotlin + Jetpack Compose
- LLM:OpenAI 兼容网关,平台自带 DeepSeek,用户可配 Ollama 等
## 状态
方案已定稿 v1.0(六项关键决策已确认)。下一步:M0 骨架。
+36
View File
@@ -0,0 +1,36 @@
# 安卓端(M0 骨架)
Kotlin + Jetpack Compose。当前只做了 M0 的骨架:登录、工作区列表、新建工作区、生成邀请码。
任务/工单/知识(M1 起)在 `ui/` 下按同样结构扩展。
## 打开与运行
1. Android Studio 打开 `android/` 目录(它会把缺失的 Gradle Wrapper 补齐)。
2. 在 `android/local.properties` 里指定后端地址(模拟器访问宿主机用 `10.0.2.2`):
```
dadao.apiBase=http://10.0.2.2:8100
```
3. 运行 `app` 配置。后端需先在本机 8100 端口起来。
> 仓库里**没有提交 `gradle/wrapper/gradle-wrapper.jar`**(二进制,且 M0 阶段由 Android Studio 生成更稳)。
> 等 M5 正式做安卓时把 wrapper 一起提交,保证构建可复现。
## 结构
```
app/src/main/java/com/dadao/task/
├── MainActivity.kt 入口 + 极简三态路由(Splash/Login/Home)
├── data/
│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException
│ └── TokenStore.kt 令牌本地存储(M5 换 EncryptedSharedPreferences)
└── ui/
├── Theme.kt 与 Web 一致的墨青 + 陶土色板
├── LoginScreen.kt 手机号 + 验证码(开发环境后端回显验证码)
└── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
```
## 已知待办
- 明文令牌存储(`TokenStore` 里已标 TODO),M5 换 Keystore 加密。
- 无离线缓存,弱网直接报错——与产品 D7「在线优先」一致。
- 邀请链接 `https://dadaotask.com/join/<wsId>/<code>` 已在 Manifest 注册,但落地处理尚未实现。
+60
View File
@@ -0,0 +1,60 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
/**
* 后端地址不走硬编码:优先读 local.properties 的 dadao.apiBase,
* 缺省指向模拟器访问宿主机的 10.0.2.2:8100。
*/
val apiBase: String = run {
val props = Properties()
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { props.load(it) }
props.getProperty("dadao.apiBase") ?: "http://10.0.2.2:8100"
}
android {
namespace = "com.dadao.task"
compileSdk = 35
defaultConfig {
applicationId = "com.dadao.task"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
buildConfigField("String", "API_BASE", "\"$apiBase\"")
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:label="达道Task"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.DadaoTask">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- 邀请链接落地: https://<domain>/join/<wsId>/<code> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,69 @@
package com.dadao.task
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.dadao.task.data.Api
import com.dadao.task.data.TokenStore
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.LoginScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DadaoTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
AppRoot()
}
}
}
}
}
private enum class Screen { Splash, Login, Home }
@Composable
private fun AppRoot() {
val context = androidx.compose.ui.platform.LocalContext.current
val tokens = remember { TokenStore(context) }
val api = remember { Api(tokens) }
var screen by remember { mutableStateOf(if (tokens.isLoggedIn) Screen.Splash else Screen.Login) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) {
if (screen == Screen.Splash) {
screen = try {
api.me()
Screen.Home
} catch (e: Exception) {
tokens.clear()
Screen.Login
}
}
}
when (screen) {
Screen.Splash -> Unit // 校验中,空白过渡即可
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
Screen.Home -> HomeScreen(
api = api,
onLogout = {
tokens.clear()
screen = Screen.Login
},
)
}
}
@@ -0,0 +1,168 @@
package com.dadao.task.data
import com.dadao.task.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/** 与后端信封一致的错误类型。 */
class ApiException(val code: Int, override val message: String, val requestId: String? = null) :
Exception(message)
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
@Serializable
private data class Envelope<T>(
val code: Int,
val message: String,
val data: T? = null,
@SerialName("request_id") val requestId: String? = null,
)
// ---- 与 backend/app/api/schemas.py 一一对应 ----
@Serializable
data class User(val id: String, val phone: String, @SerialName("display_name") val displayName: String)
@Serializable
data class Tokens(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("expires_in") val expiresIn: Int,
)
@Serializable
data class LoginResult(val user: User, val tokens: Tokens)
@Serializable
data class Workspace(
val id: String,
val name: String,
val slug: String,
val role: String? = null,
)
@Serializable
data class MeResult(val user: User, val workspaces: List<Workspace> = emptyList())
@Serializable
data class Member(
val userId: String,
@SerialName("display_name") val displayName: String,
val role: String,
val status: String,
)
@Serializable
data class Invitation(
val code: String,
@SerialName("expires_at") val expiresAt: String,
)
private data class SmsRequest(val phone: String)
private data class LoginRequest(val phone: String, val code: String)
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
private data class WorkspaceCreateRequest(val name: String)
/**
* 后端 API 客户端。
*
* 约定:所有方法返回业务 data,业务失败抛 [ApiException](code != 0),
* 与 Web 端 lib/client.ts 的行为保持一致。
*/
class Api(private val tokens: TokenStore) {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
// 与 backend/app/config.py 的 api_prefix 保持一致
private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1"
suspend fun sendSms(phone: String): JsonElement? = call(
path = "/auth/sms/send",
body = json.encodeToString(SmsRequest(phone)),
)
suspend fun login(phone: String, code: String): LoginResult {
val result: LoginResult = call(
path = "/auth/login",
body = json.encodeToString(LoginRequest(phone, code)),
)
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
return result
}
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
suspend fun workspaces(): List<Workspace> = call(path = "/workspaces", auth = true)
suspend fun createWorkspace(name: String): Workspace = call(
path = "/workspaces",
body = json.encodeToString(WorkspaceCreateRequest(name)),
auth = true,
)
suspend fun members(workspaceId: String): List<Member> =
call(path = "/workspaces/$workspaceId/members", auth = true)
suspend fun createInvitation(workspaceId: String): Invitation =
call(path = "/workspaces/$workspaceId/invitations", body = "{}", auth = true)
suspend fun join(workspaceId: String, code: String): Workspace = call(
path = "/workspaces/$workspaceId/join",
body = json.encodeToString(mapOf("code" to code)),
auth = true,
)
private suspend inline fun <reified T> call(
path: String,
body: String? = null,
auth: Boolean = false,
method: String? = null,
): T = withContext(Dispatchers.IO) {
val builder = Request.Builder().url(base + path)
val verb = method ?: if (body == null) "GET" else "POST"
if (body != null) {
builder.method(verb, body.toRequestBody(jsonMedia))
} else {
builder.method(verb, null)
}
builder.header("Accept", "application/json")
if (auth) {
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
}
http.newCall(builder.build()).execute().use { response ->
val text = response.body?.string().orEmpty()
if (text.isBlank()) {
throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})")
}
val envelope = runCatching { json.decodeFromString<Envelope<T>>(text) }.getOrElse {
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
}
if (envelope.code != 0) {
if (envelope.code == 2002 && auth) tokens.clear()
throw ApiException(envelope.code, envelope.message, envelope.requestId)
}
envelope.data ?: throw ApiException(1500, "响应缺少 data")
}
}
}
@@ -0,0 +1,38 @@
package com.dadao.task.data
import android.content.Context
import android.content.SharedPreferences
/**
* 令牌存储。
*
* TODO(M5): 换成 EncryptedSharedPreferences / Keystore 包装,明文 SP 只用于 M0 联调。
*/
class TokenStore(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("dadao.session", Context.MODE_PRIVATE)
var accessToken: String?
get() = prefs.getString(KEY_ACCESS, null)
set(value) = prefs.edit().putString(KEY_ACCESS, value).apply()
var refreshToken: String?
get() = prefs.getString(KEY_REFRESH, null)
set(value) = prefs.edit().putString(KEY_REFRESH, value).apply()
fun save(access: String, refresh: String) {
prefs.edit().putString(KEY_ACCESS, access).putString(KEY_REFRESH, refresh).apply()
}
fun clear() {
prefs.edit().remove(KEY_ACCESS).remove(KEY_REFRESH).apply()
}
val isLoggedIn: Boolean get() = !accessToken.isNullOrBlank()
private companion object {
const val KEY_ACCESS = "access"
const val KEY_REFRESH = "refresh"
}
}
@@ -0,0 +1,184 @@
package com.dadao.task.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
private val ROLE_LABEL = mapOf(
"owner" to "所有者",
"admin" to "管理员",
"member" to "成员",
"guest" to "访客",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(api: Api, onLogout: () -> Unit) {
val workspaces = remember { mutableStateListOf<Workspace>() }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var showCreate by remember { mutableStateOf(false) }
var invitation by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
suspend fun reload() {
loading = true
error = null
try {
workspaces.clear()
workspaces.addAll(api.workspaces())
} catch (e: ApiException) {
error = e.message
} finally {
loading = false
}
}
LaunchedEffect(Unit) { reload() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("我的工作区") },
actions = { TextButton(onClick = onLogout) { Text("退出") } },
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreate = true }) { Text("+") }
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
}
when {
loading -> Text("加载中…", style = MaterialTheme.typography.bodyMedium)
workspaces.isEmpty() -> Text(
"还没有工作区。点右下角 + 新建一个,任务、工单、知识都放在里面。",
style = MaterialTheme.typography.bodyMedium,
)
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(workspaces, key = { it.id }) { ws ->
Card(
modifier = Modifier.fillMaxWidth().clickable {
scope.launch {
try {
val inv = api.createInvitation(ws.id)
invitation = "${ws.name}\n邀请码 ${inv.code}\n有效期至 ${inv.expiresAt}\n\n分享链接:/join/${ws.id}/${inv.code}"
} catch (e: ApiException) {
error = e.message
}
}
},
colors = CardDefaults.cardColors(),
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(ws.name, style = MaterialTheme.typography.titleMedium)
Text(
ws.slug,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
}
Text(
ROLE_LABEL[ws.role] ?: (ws.role ?: ""),
style = MaterialTheme.typography.labelMedium,
color = Accent,
)
}
}
}
}
}
Spacer(Modifier.height(8.dp))
Text(
"点工作区可生成邀请码(M0 临时入口,M1 换成工作区详情页)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f),
)
}
}
if (showCreate) {
var name by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showCreate = false },
title = { Text("新建工作区") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it.take(40) },
label = { Text("名称") },
singleLine = true,
)
},
confirmButton = {
TextButton(enabled = !busy && name.isNotBlank(), onClick = {
scope.launch {
busy = true
try {
api.createWorkspace(name.trim())
showCreate = false
reload()
} catch (e: ApiException) {
error = e.message
} finally {
busy = false
}
}
}) { Text("创建") }
},
dismissButton = { TextButton(onClick = { showCreate = false }) { Text("取消") } },
)
}
invitation?.let { text ->
AlertDialog(
onDismissRequest = { invitation = null },
title = { Text("邀请成员") },
text = { Text(text) },
confirmButton = { TextButton(onClick = { invitation = null }) { Text("知道了") } },
)
}
}
@@ -0,0 +1,136 @@
package com.dadao.task.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var devCode by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var cooldown by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
fun sendCode() {
if (phone.length < 6 || cooldown > 0) return
scope.launch {
busy = true
error = null
try {
val data = api.sendSms(phone)
// 开发环境后端会回显验证码,联调时省事;生产不会返回。
devCode = data?.toString()?.let { Regex("\"dev_code\"\\s*:\\s*\"(\\d+)\"").find(it)?.groupValues?.get(1) }
cooldown = 60
} catch (e: ApiException) {
error = e.message
} finally {
busy = false
}
}
}
fun submit() {
if (phone.isBlank() || code.isBlank()) return
scope.launch {
busy = true
error = null
try {
api.login(phone, code)
onLoggedIn()
} catch (e: ApiException) {
error = e.message
} finally {
busy = false
}
}
}
Column(
modifier = Modifier.fillMaxSize().padding(28.dp),
verticalArrangement = Arrangement.Center,
) {
Text("达道Task", style = MaterialTheme.typography.headlineMedium)
Text(
"从任务到知识,一条道走到底",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter(Char::isDigit).take(11) },
label = { Text("手机号") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = code,
onValueChange = { code = it.filter(Char::isDigit).take(6) },
label = { Text("验证码") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
TextButton(enabled = !busy && cooldown == 0, onClick = { sendCode() }) {
Text(if (cooldown > 0) "${cooldown}s" else "获取验证码")
}
},
)
devCode?.let {
Spacer(Modifier.height(8.dp))
Text("开发环境验证码:$it", style = MaterialTheme.typography.bodySmall, color = Accent)
}
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { submit() },
enabled = !busy && phone.length == 11 && code.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
if (busy) CircularProgressIndicator(Modifier.height(20.dp), strokeWidth = 2.dp)
else Text("登录 / 注册")
}
Spacer(Modifier.height(12.dp))
Text(
"未注册的手机号将自动创建账号",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
@@ -0,0 +1,30 @@
package com.dadao.task.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// 与 Web 端 tailwind.config.js 的色板保持一致:墨青主色 + 陶土色点缀。
val Ink = Color(0xFF3F5A68)
val InkDark = Color(0xFF2C4049)
val Accent = Color(0xFFC2703D)
val Paper = Color(0xFFF7F8F8)
private val colors = lightColorScheme(
primary = Ink,
onPrimary = Color.White,
primaryContainer = Color(0xFFDCE6EA),
onPrimaryContainer = InkDark,
secondary = Accent,
onSecondary = Color.White,
background = Paper,
surface = Color.White,
onSurface = Color(0xFF1B2528),
)
@Composable
fun DadaoTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = colors, typography = Typography(), content = content)
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose 自己管配色,这里只提供无 ActionBar 的容器主题 -->
<style name="Theme.DadaoTask" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">#3F5A68</item>
<item name="android:windowBackground">#F7F8F8</item>
</style>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
只对本机开发地址放行明文 HTTP。生产域名必须走 HTTPS,不放行任何 cleartext。
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "DadaoTask"
include(":app")
+18
View File
@@ -0,0 +1,18 @@
# 复制为 .env 后按需修改;.env 不进仓库
ENV=dev
APP_NAME=达道Task
# 应用角色(受 RLS 约束)—— 应用进程只能用它
DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask
# 迁移角色(owner,绕过 RLS)—— 只有 alembic 用
MIGRATION_DATABASE_URL=postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask
REDIS_URL=redis://127.0.0.1:6380/0
JWT_SECRET=dev-only-change-me
ACCESS_TOKEN_MINUTES=30
REFRESH_TOKEN_DAYS=14
SMS_PROVIDER=dev
SMS_DEV_CODE=123456
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+66
View File
@@ -0,0 +1,66 @@
"""Alembic 环境:用**迁移角色**(owner)连接,与应用的受限角色分开。"""
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().migration_database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
# RLS 策略、GRANT 这类"非表结构"变更 autogenerate 看不见,必须手写
include_schemas=False,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+27
View File
@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+197
View File
@@ -0,0 +1,197 @@
"""M0 初始结构:workspace / app_user / member / invitation / audit_log + 行级安全。
注意:RLS 策略是**安全边界**,不是优化项。改这个文件请同时改
`tests/test_tenant_isolation.py`,并在 PR 里说明为什么。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
HELPER_SQL = """
CREATE OR REPLACE FUNCTION app_ws_id() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT nullif(current_setting('app.ws_id', true), '')::uuid
$$;
CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT nullif(current_setting('app.user_id', true), '')::uuid
$$;
"""
POLICY_SQL = f"""
-- ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")──
ALTER TABLE workspace ENABLE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON workspace
USING (
id = app_ws_id()
OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id())
)
WITH CHECK (id = app_ws_id());
-- ── member:本租户成员 OR 我自己那条 ──
ALTER TABLE member ENABLE ROW LEVEL SECURITY;
CREATE POLICY member_isolation ON member
USING (workspace_id = app_ws_id() OR user_id = app_user_id())
WITH CHECK (workspace_id = app_ws_id());
-- ── invitation ──
ALTER TABLE invitation ENABLE ROW LEVEL SECURITY;
CREATE POLICY invitation_isolation ON invitation
USING (workspace_id = app_ws_id())
WITH CHECK (workspace_id = app_ws_id());
-- ── audit_log:只增不改,租户内可见 ──
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY audit_log_isolation ON audit_log
USING (workspace_id = app_ws_id())
WITH CHECK (workspace_id = app_ws_id());
-- ── 授权:应用角色没有 DDL、没有 BYPASSRLS,只能读写到自己该看的数据 ──
GRANT USAGE ON SCHEMA public TO {APP_ROLE};
GRANT SELECT, INSERT, UPDATE, DELETE ON
workspace, app_user, member, invitation, audit_log TO {APP_ROLE};
ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};
"""
def upgrade() -> None:
op.execute(HELPER_SQL)
op.create_table(
"app_user",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("phone", sa.String(32), nullable=False, unique=True),
sa.Column("nickname", sa.String(64), nullable=False, server_default=""),
sa.Column("avatar_url", sa.String(512)),
sa.Column("status", sa.String(16), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
)
op.create_index("ix_app_user_phone", "app_user", ["phone"])
op.create_table(
"workspace",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("slug", sa.String(64), nullable=False, unique=True),
sa.Column("plan", sa.String(16), nullable=False, server_default="free"),
sa.Column(
"owner_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
)
op.create_index("ix_workspace_owner", "workspace", ["owner_id"])
op.create_table(
"member",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("role", sa.String(16), nullable=False, server_default="member"),
sa.Column("status", sa.String(16), nullable=False, server_default="active"),
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
sa.UniqueConstraint("workspace_id", "user_id", name="uq_member_ws_user"),
sa.CheckConstraint("role IN ('owner','admin','member','guest')", name="ck_member_role"),
sa.CheckConstraint("status IN ('active','removed')", name="ck_member_status"),
)
op.create_index("ix_member_user", "member", ["user_id"])
op.create_index("ix_member_created", "member", ["created_at", "id"])
op.create_table(
"invitation",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("code", sa.String(32), nullable=False),
sa.Column("role", sa.String(16), nullable=False, server_default="member"),
sa.Column(
"invited_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True)),
sa.Column(
"accepted_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
sa.UniqueConstraint("code", name="uq_invitation_code"),
)
op.create_index("ix_invitation_ws", "invitation", ["workspace_id"])
op.create_table(
"audit_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="SET NULL"),
),
sa.Column(
"actor_user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
),
sa.Column("action", sa.String(64), nullable=False),
sa.Column("target_type", sa.String(32), nullable=False, server_default=""),
sa.Column("target_id", sa.String(64), nullable=False, server_default=""),
sa.Column("payload", postgresql.JSONB),
sa.Column("ip", sa.String(64), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_audit_ws_created", "audit_log", ["workspace_id", "created_at"])
op.execute(POLICY_SQL)
def downgrade() -> None:
op.execute(f"REVOKE ALL ON workspace, app_user, member, invitation, audit_log FROM {APP_ROLE};")
op.drop_table("audit_log")
op.drop_table("invitation")
op.drop_table("member")
op.drop_table("workspace")
op.drop_table("app_user")
op.execute("DROP FUNCTION IF EXISTS app_ws_id(); DROP FUNCTION IF EXISTS app_user_id();")
View File
View File
+142
View File
@@ -0,0 +1,142 @@
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
from __future__ import annotations
import hmac
import secrets
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from ..config import get_settings
from ..deps import CurrentUser, SessionDep
from ..errors import AppError, ErrorCode
from ..models import AppUser, Member, Workspace
from ..schemas import Envelope, ok
from ..security import create_token_pair, decode_token
from ..stores import get_store, sms_code_key, sms_throttle_key
router = APIRouter(prefix="/auth", tags=["认证"])
settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$"
def mask_phone(phone: str) -> str:
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
class SendCodeIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
class LoginIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN)
code: str = Field(min_length=4, max_length=8)
class RefreshIn(BaseModel):
refresh_token: str = Field(min_length=10)
@router.post("/sms/send", summary="发送登录验证码")
async def send_code(body: SendCodeIn) -> dict:
store = get_store()
hits = await store.incr(sms_throttle_key(body.phone), 60)
if hits > 1:
raise AppError(ErrorCode.SMS_TOO_FREQUENT, "发送过于频繁,请 60 秒后重试")
if settings.sms_provider == "dev":
code = settings.sms_dev_code
else:
code = f"{secrets.randbelow(1_000_000):06d}"
# TODO(M0 遗留):接入阿里云/腾讯云短信;当前直接在响应里不回显验证码
raise AppError(ErrorCode.INTERNAL, "短信通道尚未接入", http_status=501)
await store.set(sms_code_key(body.phone), code, settings.sms_code_ttl_seconds)
return ok({"sent": True, "expires_in": settings.sms_code_ttl_seconds, "dev_code": code})
@router.post("/login", summary="验证码登录(不存在则自动注册)")
async def login(body: LoginIn, session: SessionDep) -> dict:
store = get_store()
stored = await store.get(sms_code_key(body.phone))
if stored is None:
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码已过期,请重新获取")
if not hmac.compare_digest(stored, body.code):
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码错误")
await store.delete(sms_code_key(body.phone))
user = (
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
).scalar_one_or_none()
if user is None:
user = AppUser(phone=body.phone, nickname=f"用户{body.phone[-4:]}")
session.add(user)
await session.flush()
tokens = create_token_pair(user.id)
return ok(
{
"user": {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
"created_at": user.created_at,
},
"tokens": {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
},
}
)
@router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshIn) -> dict:
user_id = decode_token(body.refresh_token, "refresh")
tokens = create_token_pair(user_id)
return ok(
{
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
)
@router.get("/me", summary="当前用户与所属工作区")
async def me(user: CurrentUser, session: SessionDep) -> dict:
rows = (
await session.execute(
select(Workspace, Member.role)
.join(Member, Member.workspace_id == Workspace.id)
.where(Member.user_id == user.id, Member.status == "active")
.order_by(Workspace.created_at.desc())
)
).all()
return ok(
{
"user": {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
},
"workspaces": [
{
"id": str(ws.id),
"name": ws.name,
"slug": ws.slug,
"plan": ws.plan,
"role": role,
"created_at": ws.created_at,
}
for ws, role in rows
],
}
)
+67
View File
@@ -0,0 +1,67 @@
"""对外输出模型。字段命名一次定死,客户端按契约生成。"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
phone: str = Field(description="已打码的手机号,如 138****8000")
nickname: str
created_at: datetime
class TokenOut(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class LoginOut(BaseModel):
user: UserOut
tokens: TokenOut
class WorkspaceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
slug: str
plan: str
role: str = ""
created_at: datetime
class MemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
user_id: uuid.UUID
role: str
status: str
nickname: str = ""
phone: str = ""
joined_at: datetime
class InvitationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
code: str
role: str
expires_at: datetime
accepted_at: datetime | None = None
class MeOut(BaseModel):
user: UserOut
workspaces: list[WorkspaceOut]
+240
View File
@@ -0,0 +1,240 @@
"""工作区 / 成员 / 邀请。M0 的验收核心:两个租户互相看不见。"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import select, tuple_
from ..audit import record as audit
from ..db import bind_tenant
from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep
from ..errors import AppError, ErrorCode
from ..models import AppUser, Invitation, Member, Workspace
from ..schemas import Page, decode_cursor, encode_cursor, ok
from ..security import invite_code_matches, new_invite_code
router = APIRouter(prefix="/workspaces", tags=["工作区"])
ROLE_CHOICES = ("admin", "member", "guest")
class CreateWorkspaceIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
slug: str | None = Field(default=None, max_length=64)
class CreateInvitationIn(BaseModel):
role: str = Field(default="member")
expires_hours: int = Field(default=72, ge=1, le=24 * 30)
class JoinIn(BaseModel):
code: str = Field(min_length=4, max_length=32)
def _ws_out(ws: Workspace, role: str) -> dict:
return {
"id": str(ws.id),
"name": ws.name,
"slug": ws.slug,
"plan": ws.plan,
"role": role,
"created_at": ws.created_at,
}
@router.post("", summary="创建工作区(创建者成为 owner)")
async def create_workspace(
body: CreateWorkspaceIn, user: CurrentUser, session: SessionDep, request: Request
) -> dict:
ws_id = uuid.uuid4()
slug = body.slug or f"ws-{ws_id.hex[:8]}"
exists = (
await session.execute(select(Workspace.id).where(Workspace.slug == slug))
).scalar_one_or_none()
if exists is not None:
raise AppError(ErrorCode.CONFLICT, "该 slug 已被占用")
# 先绑租户再插数据:workspace 的 RLS 策略是 id = app.ws_id,
# 插入时 Postgres 用 WITH CHECK 校验,不绑就会直接被拒。
await bind_tenant(session, ws_id, user.id)
ws = Workspace(id=ws_id, name=body.name, slug=slug, owner_id=user.id, plan="free")
session.add(ws)
session.add(
Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active")
)
await session.flush()
await audit(
session,
workspace_id=ws_id,
actor_user_id=user.id,
action="workspace.create",
target_type="workspace",
target_id=str(ws_id),
payload={"name": body.name},
ip=request.client.host if request.client else "",
)
return ok(_ws_out(ws, "owner"))
@router.get("", summary="我的工作区列表")
async def list_workspaces(user: CurrentUser, session: SessionDep) -> dict:
rows = (
await session.execute(
select(Workspace, Member.role)
.join(Member, Member.workspace_id == Workspace.id)
.where(Member.user_id == user.id, Member.status == "active")
.order_by(Workspace.created_at.desc())
)
).all()
return ok({"items": [_ws_out(ws, role) for ws, role in rows]})
@router.get("/{ws_id}", summary="工作区详情")
async def get_workspace(ctx: MemberDep, session: SessionDep) -> dict:
ws = await session.get(Workspace, ctx.ws_id)
if ws is None:
raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "工作区不存在")
return ok(_ws_out(ws, ctx.role))
@router.get("/{ws_id}/members", summary="成员列表(游标分页)")
async def list_members(
ctx: MemberDep,
session: SessionDep,
limit: int = Query(default=20, ge=1, le=100),
cursor: str | None = Query(default=None),
) -> dict:
stmt = (
select(Member, AppUser)
.join(AppUser, AppUser.id == Member.user_id)
.where(Member.workspace_id == ctx.ws_id)
)
if cursor:
cur = decode_cursor(cursor)
stmt = stmt.where(tuple_(Member.created_at, Member.id) < (cur.created_at, cur.id))
stmt = stmt.order_by(Member.created_at.desc(), Member.id.desc()).limit(limit + 1)
rows = (await session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
items = [
{
"id": str(m.id),
"user_id": str(m.user_id),
"role": m.role,
"status": m.status,
"nickname": u.nickname,
"phone": f"{u.phone[:3]}****{u.phone[-4:]}",
"joined_at": m.joined_at,
}
for m, u in rows
]
next_cursor = (
encode_cursor(rows[-1][0].created_at, rows[-1][0].id) if has_more and rows else None
)
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
@router.post("/{ws_id}/invitations", summary="生成邀请码(admin 及以上)")
async def create_invitation(
body: CreateInvitationIn, ctx: AdminDep, session: SessionDep, request: Request
) -> dict:
if body.role not in ROLE_CHOICES:
raise AppError(ErrorCode.BAD_REQUEST, f"角色只能是 {', '.join(ROLE_CHOICES)}")
if body.role == "admin" and ctx.role != "owner":
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有 owner 能邀请 admin")
invitation = Invitation(
workspace_id=ctx.ws_id,
code=new_invite_code(),
role=body.role,
invited_by=ctx.user.id,
expires_at=datetime.now(UTC) + timedelta(hours=body.expires_hours),
)
session.add(invitation)
await session.flush()
await audit(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="invitation.create",
target_type="invitation",
target_id=str(invitation.id),
payload={"role": body.role},
ip=request.client.host if request.client else "",
)
return ok(
{
"id": str(invitation.id),
"code": invitation.code,
"role": invitation.role,
"expires_at": invitation.expires_at,
}
)
@router.post("/{ws_id}/join", summary="凭邀请码加入工作区")
async def join_workspace(
body: JoinIn, ctx: TenantDep, session: SessionDep, request: Request
) -> dict:
invitation = (
await session.execute(
select(Invitation).where(
Invitation.workspace_id == ctx.ws_id,
Invitation.accepted_at.is_(None),
)
)
).scalars()
matched = next(
(inv for inv in invitation if invite_code_matches(body.code, inv.code)), None
)
if matched is None:
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码无效或已使用")
if matched.expires_at < datetime.now(UTC):
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码已过期")
existing = (
await session.execute(
select(Member).where(
Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id
)
)
).scalar_one_or_none()
if existing is not None and existing.status == "active":
raise AppError(ErrorCode.ALREADY_A_MEMBER, "你已经是该工作区成员")
if existing is None:
session.add(
Member(
workspace_id=ctx.ws_id,
user_id=ctx.user.id,
role=matched.role,
status="active",
)
)
else:
existing.role = matched.role
existing.status = "active"
matched.accepted_at = datetime.now(UTC)
matched.accepted_by = ctx.user.id
await session.flush()
await audit(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="member.join",
target_type="member",
target_id=str(ctx.user.id),
payload={"role": matched.role},
ip=request.client.host if request.client else "",
)
ws = await session.get(Workspace, ctx.ws_id)
return ok(_ws_out(ws, matched.role) if ws else None)
+35
View File
@@ -0,0 +1,35 @@
"""审计日志写入。所有敏感操作(建工作区、邀请、加入、移除成员)都要留痕。"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from .models import AuditLog
async def record(
session: AsyncSession,
*,
workspace_id: uuid.UUID | None,
actor_user_id: uuid.UUID | None,
action: str,
target_type: str = "",
target_id: str = "",
payload: dict[str, Any] | None = None,
ip: str = "",
) -> None:
session.add(
AuditLog(
workspace_id=workspace_id,
actor_user_id=actor_user_id,
action=action,
target_type=target_type,
target_id=target_id,
payload=payload or {},
ip=ip,
)
)
await session.flush()
+93
View File
@@ -0,0 +1,93 @@
"""分层配置:dev / prod 全部走环境变量,启动即校验。"""
from __future__ import annotations
import os
from functools import lru_cache
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
DEV_JWT_SECRET = "dev-only-change-me"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False
)
# ── 基础 ──────────────────────────────────────────────
app_name: str = "达道Task"
env: Literal["dev", "test", "prod"] = "dev"
api_prefix: str = "/api/v1"
cors_origins: list[str] = Field(
default_factory=lambda: ["http://localhost:5180", "http://127.0.0.1:5180"]
)
# ── 数据库 ────────────────────────────────────────────
# 应用角色:受 RLS 约束,非 owner、非 superuser
database_url: str = (
"postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
)
# 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用
migration_database_url: str = (
"postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask"
)
db_echo: bool = False
db_pool_size: int = 10
# ── Redis ─────────────────────────────────────────────
redis_url: str = "redis://127.0.0.1:6380/0"
# ── 认证 ──────────────────────────────────────────────
jwt_secret: str = DEV_JWT_SECRET
jwt_algorithm: str = "HS256"
access_token_minutes: int = 30
refresh_token_days: int = 14
sms_code_ttl_seconds: int = 300
sms_dev_code: str = "123456"
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
# ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True
idempotency_ttl_seconds: int = 86_400
# ── 构建标记(部署校验用)──────────────────────────────
build_commit: str = "unknown"
build_time: str = "unknown"
@field_validator("cors_origins", mode="before")
@classmethod
def _split_origins(cls, value: object) -> object:
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
if isinstance(value, str) and not value.startswith("["):
return [item.strip() for item in value.split(",") if item.strip()]
return value
def validate_runtime(self) -> None:
"""生产环境的致命配置错误,必须在启动时炸掉,而不是等待请求。"""
if self.env != "prod":
return
problems: list[str] = []
if self.jwt_secret == DEV_JWT_SECRET or len(self.jwt_secret) < 32:
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
if "dadao_dev_pw" in self.database_url:
problems.append("DATABASE_URL 仍在用开发默认口令")
if self.sms_provider == "dev":
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems))
@lru_cache
def get_settings() -> Settings:
return Settings()
def build_commit() -> str:
"""优先读环境变量(CI/镜像注入),退回 git。"""
env_value = os.getenv("BUILD_COMMIT")
if env_value:
return env_value
return get_settings().build_commit
+94
View File
@@ -0,0 +1,94 @@
"""数据库会话与租户上下文。
安全模型(务必理解,否则会写出越权 bug):
1. 应用进程用 **dadao_app** 角色连库 —— 非 owner、非 superuser、非 bypassrls。
owner/superuser 会**绕过 RLS**,所以应用绝不使用迁移角色。
2. 每个请求在**同一个事务**里先 `set_config('app.ws_id', ..., true)`
(true = 事务级,事务结束自动失效,不会污染连接池里的下一条请求)。
3. RLS 策略拿这个变量做过滤。变量没设 → 策略为 false → 一行都查不到。
4. 仓储层强制 ws_id 必填,双保险:即使 RLS 被误关,代码也不会跨租户查。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from .config import get_settings
from .errors import AppError, ErrorCode
_settings = get_settings()
_engine_kwargs: dict[str, Any] = {"echo": _settings.db_echo, "pool_pre_ping": True}
if _settings.env == "test":
# pytest-asyncio 每个用例一个事件循环,连接池跨循环复用会报
# "attached to a different loop",测试环境干脆不池化。
_engine_kwargs["poolclass"] = NullPool
else:
_engine_kwargs["pool_size"] = _settings.db_pool_size
engine = create_async_engine(_settings.database_url, **_engine_kwargs)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
async def get_session() -> AsyncIterator[AsyncSession]:
"""一个请求 = 一个事务。异常即整体回滚。"""
async with SessionFactory() as session:
async with session.begin():
yield session
async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None:
"""把租户上下文写进当前事务。必须在任何业务查询之前调用。"""
await session.execute(
text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)}
)
if user_id is not None:
await session.execute(
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
)
async def bind_user(session: AsyncSession, user_id: uuid.UUID) -> None:
"""只绑用户(登录后尚未选定工作区的场景)。"""
await session.execute(
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
)
async def current_app_role(session: AsyncSession) -> dict[str, Any]:
"""自检用:确认应用连接的不是特权角色。"""
row = (
await session.execute(
text(
"SELECT current_user AS role, "
"pg_has_role(current_user, 'pg_read_all_data', 'member') AS read_all, "
"(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_super, "
"(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls"
)
)
).mappings().one()
return dict(row)
class Repo:
"""仓储基类:ws_id 必填,且没有默认值——不给"忘了传"留余地。"""
def __init__(self, session: AsyncSession, ws_id: uuid.UUID) -> None:
if ws_id is None:
raise AppError(ErrorCode.BAD_REQUEST, "缺少工作区上下文")
self.session = session
self.ws_id = ws_id
class UserRepo:
"""全局仓储(app_user 无租户),仅认证流程使用。"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
+114
View File
@@ -0,0 +1,114 @@
"""请求依赖:认证、租户上下文、角色校验。
关键点:**租户上下文必须在任何业务查询之前绑定**,否则 RLS 会返回空集,
表现为"数据凭空消失"而不是报错——这类 bug 最难查,所以封装成依赖强制调用。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, Header, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .db import bind_tenant, bind_user, get_session
from .errors import AppError, ErrorCode
from .logging_setup import user_id_ctx, workspace_id_ctx
from .models import AppUser, Member
from .security import decode_token
ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1}
@dataclass(frozen=True)
class AuthUser:
id: uuid.UUID
phone: str
nickname: str
@dataclass(frozen=True)
class TenantCtx:
ws_id: uuid.UUID
user: AuthUser
role: str
def require_role(self, minimum: str) -> None:
if ROLE_RANK.get(self.role, 0) < ROLE_RANK[minimum]:
raise AppError(ErrorCode.INSUFFICIENT_ROLE, f"需要 {minimum} 及以上权限")
SessionDep = Annotated[AsyncSession, Depends(get_session)]
async def get_bearer_token(authorization: Annotated[str | None, Header()] = None) -> str:
if not authorization or not authorization.lower().startswith("bearer "):
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问令牌")
return authorization.split(" ", 1)[1].strip()
TokenDep = Annotated[str, Depends(get_bearer_token)]
async def current_user(token: TokenDep, session: SessionDep) -> AuthUser:
user_id = decode_token(token, "access")
user = await session.get(AppUser, user_id)
if user is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if user.status != "active":
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
# 绑定 user_id:member 表策略允许"看到自己的成员记录",用于"我加入了哪些工作区"
await bind_user(session, user.id)
user_id_ctx.set(str(user.id))
return AuthUser(id=user.id, phone=user.phone, nickname=user.nickname)
CurrentUser = Annotated[AuthUser, Depends(current_user)]
async def tenant_ctx(
ws_id: Annotated[uuid.UUID, Path(alias="ws_id")],
user: CurrentUser,
session: SessionDep,
) -> TenantCtx:
"""仅绑定租户(不要求是成员)。用于"凭邀请码加入工作区"这类场景。"""
await bind_tenant(session, ws_id, user.id)
workspace_id_ctx.set(str(ws_id))
return TenantCtx(ws_id=ws_id, user=user, role="")
TenantDep = Annotated[TenantCtx, Depends(tenant_ctx)]
async def member_ctx(ctx: TenantDep, session: SessionDep) -> TenantCtx:
"""要求是有效成员,并把角色带进上下文。"""
row = await session.execute(
select(Member).where(
Member.workspace_id == ctx.ws_id,
Member.user_id == ctx.user.id,
Member.status == "active",
)
)
member = row.scalar_one_or_none()
if member is None:
# 不区分"工作区不存在"和"你不是成员",避免探测他人工作区是否存在
raise AppError(ErrorCode.NOT_A_MEMBER, "无权访问该工作区")
return TenantCtx(ws_id=ctx.ws_id, user=ctx.user, role=member.role)
MemberDep = Annotated[TenantCtx, Depends(member_ctx)]
def require_role(minimum: str):
async def _guard(ctx: MemberDep) -> TenantCtx:
ctx.require_role(minimum)
return ctx
return _guard
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
+84
View File
@@ -0,0 +1,84 @@
"""错误码与异常。错误码一旦对外发布就**不许改号**(客户端靠它做分支)。"""
from __future__ import annotations
from enum import IntEnum
from typing import Any
class ErrorCode(IntEnum):
OK = 0
# 1xxx 通用
BAD_REQUEST = 1001
VALIDATION_FAILED = 1002
NOT_FOUND = 1004
CONFLICT = 1009
PAYLOAD_TOO_LARGE = 1013
RATE_LIMITED = 1029
INTERNAL = 1500
# 2xxx 认证 / 账号
UNAUTHORIZED = 2001
TOKEN_EXPIRED = 2002
TOKEN_INVALID = 2003
SMS_CODE_INVALID = 2010
SMS_TOO_FREQUENT = 2011
ACCOUNT_DISABLED = 2012
# 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001
NOT_A_MEMBER = 3003
INSUFFICIENT_ROLE = 3004
ALREADY_A_MEMBER = 3005
INVITATION_INVALID = 3010
# 4xxx 并发
VERSION_CONFLICT = 4009
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
_HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.OK: 200,
ErrorCode.BAD_REQUEST: 400,
ErrorCode.VALIDATION_FAILED: 422,
ErrorCode.NOT_FOUND: 404,
ErrorCode.CONFLICT: 409,
ErrorCode.PAYLOAD_TOO_LARGE: 413,
ErrorCode.RATE_LIMITED: 429,
ErrorCode.INTERNAL: 500,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.TOKEN_EXPIRED: 401,
ErrorCode.TOKEN_INVALID: 401,
ErrorCode.SMS_CODE_INVALID: 400,
ErrorCode.SMS_TOO_FREQUENT: 429,
ErrorCode.ACCOUNT_DISABLED: 403,
ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403,
ErrorCode.ALREADY_A_MEMBER: 409,
ErrorCode.INVITATION_INVALID: 400,
ErrorCode.VERSION_CONFLICT: 409,
}
class AppError(Exception):
"""业务异常。__str__ 只用于日志,对外一律走 code + message。"""
def __init__(
self,
code: ErrorCode,
message: str | None = None,
*,
http_status: int | None = None,
details: Any = None,
) -> None:
self.code = code
self.message = message or code.name
self.http_status = http_status or _HTTP_STATUS.get(code, 400)
self.details = details
super().__init__(f"[{int(code)}] {self.message}")
def http_status_for(code: ErrorCode) -> int:
return _HTTP_STATUS.get(code, 400)
+80
View File
@@ -0,0 +1,80 @@
"""结构化日志:JSON 行 + request_id 贯穿 + 敏感字段脱敏。
脱敏是**无条件**的:任何日志调用里的 dict 都会过一遍 _mask。
不要为了方便调试关掉它——AGENTS.md 第 7 条。
"""
from __future__ import annotations
import json
import logging
import sys
from contextvars import ContextVar
from datetime import UTC, datetime
from typing import Any
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
user_id_ctx: ContextVar[str] = ContextVar("user_id", default="-")
workspace_id_ctx: ContextVar[str] = ContextVar("workspace_id", default="-")
_SENSITIVE_HINTS = (
"password",
"passwd",
"token",
"secret",
"api_key",
"apikey",
"authorization",
"cookie",
"credential",
"private_key",
)
MASK = "***"
def _mask(value: Any, depth: int = 0) -> Any:
if depth > 6:
return "<deep>"
if isinstance(value, dict):
return {
key: (MASK if any(h in str(key).lower() for h in _SENSITIVE_HINTS) else _mask(val, depth + 1))
for key, val in value.items()
}
if isinstance(value, (list, tuple)):
return [_mask(item, depth + 1) for item in value]
return value
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
"request_id": request_id_ctx.get(),
"user_id": user_id_ctx.get(),
"ws_id": workspace_id_ctx.get(),
}
extra = getattr(record, "extra_fields", None)
if isinstance(extra, dict):
payload.update(_mask(extra))
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
def setup_logging(level: str = "INFO") -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(level)
# uvicorn 的 access log 交给自己的中间件,避免重复
for noisy in ("uvicorn.access", "sqlalchemy.engine.Engine"):
logging.getLogger(noisy).handlers = []
logging.getLogger(noisy).propagate = False
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)
+132
View File
@@ -0,0 +1,132 @@
"""FastAPI 入口。"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text
from starlette.exceptions import HTTPException as StarletteHTTPException
from .api import auth, workspaces
from .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
from .logging_setup import get_logger, request_id_ctx, setup_logging
from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body
settings = get_settings()
setup_logging("DEBUG" if settings.env == "dev" else "INFO")
logger = get_logger("app")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings.validate_runtime()
logger.info("服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}})
yield
await engine.dispose()
app = FastAPI(
title=f"{settings.app_name} API",
version="0.1.0",
description="达道Task —— 任务 / 工单 / 知识库,一条流。",
lifespan=lifespan,
docs_url="/docs" if settings.env != "prod" else None,
redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-Id", "X-Idempotent-Replay"],
)
# 后加的中间件先执行:RequestContext 必须最外层
app.add_middleware(IdempotencyMiddleware)
app.add_middleware(RequestContextMiddleware)
# ── 异常处理:对外永远是 {code, message, data, request_id} ──
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.http_status,
content=error_body(request_id_ctx.get(), exc.code, exc.message, exc.details),
)
@app.exception_handler(RequestValidationError)
async def handle_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
details: list[dict[str, Any]] = [
{"field": ".".join(str(p) for p in err["loc"][1:]), "msg": err["msg"]}
for err in exc.errors()
]
return JSONResponse(
status_code=422,
content=error_body(
request_id_ctx.get(), ErrorCode.VALIDATION_FAILED, "参数校验失败", details
),
)
@app.exception_handler(StarletteHTTPException)
async def handle_http(request: Request, exc: StarletteHTTPException) -> JSONResponse:
code = ErrorCode.NOT_FOUND if exc.status_code == 404 else ErrorCode.BAD_REQUEST
return JSONResponse(
status_code=exc.status_code,
content=error_body(request_id_ctx.get(), code, str(exc.detail)),
)
@app.exception_handler(Exception)
async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
logger.exception("未处理异常")
return JSONResponse(
status_code=http_status_for(ErrorCode.INTERNAL),
content=error_body(request_id_ctx.get(), ErrorCode.INTERNAL, "服务器内部错误"),
)
app.include_router(auth.router, prefix=settings.api_prefix)
app.include_router(workspaces.router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
async def health() -> dict:
db_ok = True
try:
async with SessionFactory() as session:
await session.execute(text("SELECT 1"))
except Exception: # noqa: BLE001
logger.exception("数据库探活失败")
db_ok = False
return {"status": "ok" if db_ok else "degraded", "db": db_ok, "env": settings.env}
@app.get("/version", tags=["运维"], summary="构建标记")
async def version() -> dict:
"""部署后必须校验:commit 必须等于 git rev-parse --short HEAD。"""
role = None
try:
async with SessionFactory() as session:
role = await current_app_role(session)
except Exception: # noqa: BLE001
logger.warning("读取数据库角色失败")
return {
"app": settings.app_name,
"env": settings.env,
"commit": settings.build_commit,
"build_time": settings.build_time,
"api_prefix": settings.api_prefix,
"db_role": role,
}
+142
View File
@@ -0,0 +1,142 @@
"""中间件:请求上下文(request_id / 访问日志)与幂等键。
顺序:RequestContext → Idempotency → 路由。
request_id 必须最先建立,否则后面所有日志都没有 trace。
"""
from __future__ import annotations
import json
import time
import uuid
from typing import Any
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
from .config import get_settings
from .errors import ErrorCode
from .logging_setup import get_logger, request_id_ctx
from .security import decode_token
from .stores import dumps, get_store, idempotency_key
settings = get_settings()
logger = get_logger("app.access")
_IDEMPOTENT_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
_MAX_CACHE_BYTES = 256 * 1024
class RequestContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
rid = request.headers.get("X-Request-Id") or uuid.uuid4().hex
token = request_id_ctx.set(rid)
started = time.perf_counter()
status = 500
try:
response = await call_next(request)
status = response.status_code
response.headers["X-Request-Id"] = rid
return response
finally:
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
logger.info(
"%s %s %s %sms",
request.method,
request.url.path,
status,
elapsed_ms,
extra={
"extra_fields": {
"method": request.method,
"path": request.url.path,
"status": status,
"elapsed_ms": elapsed_ms,
"client": request.client.host if request.client else "-",
}
},
)
request_id_ctx.reset(token)
class IdempotencyMiddleware(BaseHTTPMiddleware):
"""带 Idempotency-Key 的写请求,重复提交返回第一次的结果。
限制(M0 已知):同 key 并发请求存在竞态窗口,靠唯一键约束兜底留到业务层;
只缓存 JSON 且 < 256KB 的响应;5xx 不缓存。
"""
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if not settings.idempotency_enabled or request.method not in _IDEMPOTENT_METHODS:
return await call_next(request)
raw_key = request.headers.get("Idempotency-Key")
if not raw_key:
return await call_next(request)
subject = self._subject(request)
if subject is None:
# 未认证请求不做幂等(登录接口另有验证码限流)
return await call_next(request)
store = get_store()
cache_key = idempotency_key(subject, raw_key)
try:
cached = await store.get(cache_key)
except Exception: # noqa: BLE001 - 存储不可用不能拖垮业务
logger.warning("幂等存储不可用,降级为直接执行", exc_info=True)
return await call_next(request)
if cached:
payload = json.loads(cached)
return JSONResponse(
content=payload["body"],
status_code=payload["status"],
headers={"X-Idempotent-Replay": "1"},
)
response = await call_next(request)
if response.status_code >= 500:
return response
body = b""
async for chunk in response.body_iterator: # type: ignore[attr-defined]
body += chunk.encode() if isinstance(chunk, str) else chunk
if len(body) <= _MAX_CACHE_BYTES and "application/json" in response.headers.get(
"content-type", ""
):
try:
await store.set(
cache_key,
dumps({"status": response.status_code, "body": json.loads(body)}),
settings.idempotency_ttl_seconds,
)
except Exception: # noqa: BLE001
logger.warning("幂等结果写入失败", exc_info=True)
return Response(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.headers.get("content-type"),
)
@staticmethod
def _subject(request: Request) -> str | None:
header = request.headers.get("Authorization", "")
if not header.lower().startswith("bearer "):
return None
try:
return str(decode_token(header.split(" ", 1)[1].strip(), "access"))
except Exception: # noqa: BLE001 - 令牌问题交给后面的依赖统一报错
return None
def error_body(request_id: str, code: ErrorCode, message: str, details: Any = None) -> dict:
return {
"code": int(code),
"message": message,
"data": details,
"request_id": request_id,
}
+135
View File
@@ -0,0 +1,135 @@
"""数据模型(M0 范围:workspace / user / member / invitation / audit_log)。
M1 之后的任务、工单、知识表另起模块,但**继承同样的约定**:
- 主键 uuid,应用侧生成(不依赖数据库扩展,方便迁移与分库)
- 通用列 created_at / updated_at / version / deleted_at
- 所有租户表必须有 workspace_id NOT NULL(AGENTS.md 第 2.5 条)
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
def new_uuid() -> uuid.UUID:
return uuid.uuid4()
class Timestamps:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
version: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
class AppUser(Base, Timestamps):
"""全局用户,**不挂租户**(一个人可以属于多个工作区)。"""
__tablename__ = "app_user"
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
avatar_url: Mapped[str | None] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
class Workspace(Base, Timestamps):
"""租户根。RLS 策略:workspace_id(id) = current_setting('app.ws_id')。"""
__tablename__ = "workspace"
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
name: Mapped[str] = mapped_column(String(64), nullable=False)
slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
plan: Mapped[str] = mapped_column(String(16), nullable=False, default="free")
owner_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Member(Base, Timestamps):
"""成员关系。RLS 策略:本租户可见 OR 自己是本人(用于"我加入了哪些工作区")。"""
__tablename__ = "member"
__table_args__ = (
UniqueConstraint("workspace_id", "user_id", name="uq_member_ws_user"),
Index("ix_member_user", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
role: Mapped[str] = mapped_column(String(16), nullable=False, default="member")
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Invitation(Base, Timestamps):
__tablename__ = "invitation"
__table_args__ = (
UniqueConstraint("code", name="uq_invitation_code"),
Index("ix_invitation_ws", "workspace_id"),
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
code: Mapped[str] = mapped_column(String(32), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False, default="member")
invited_by: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
accepted_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
class AuditLog(Base):
"""审计日志。**只增不改**;租户内可见,平台侧另有运维通道(M7)。"""
__tablename__ = "audit_log"
__table_args__ = (Index("ix_audit_ws_created", "workspace_id", "created_at"),)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="SET NULL")
)
actor_user_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
action: Mapped[str] = mapped_column(String(64), nullable=False)
target_type: Mapped[str] = mapped_column(String(32), nullable=False, default="")
target_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
payload: Mapped[dict | None] = mapped_column(JSONB)
ip: Mapped[str] = mapped_column(String(64), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# 需要 RLS 的租户表(迁移与测试共用这一份清单,避免漏加)
TENANT_TABLES: tuple[str, ...] = ("workspace", "member", "invitation", "audit_log")
+77
View File
@@ -0,0 +1,77 @@
"""统一响应体 {code, message, data, request_id} + 游标分页。"""
from __future__ import annotations
import base64
import binascii
import json
from datetime import datetime
from typing import Any, Generic, TypeVar
from uuid import UUID
from pydantic import BaseModel, Field
from .errors import AppError, ErrorCode
from .logging_setup import request_id_ctx
T = TypeVar("T")
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
class Envelope(BaseModel, Generic[T]):
code: int = 0
message: str = "ok"
data: T | None = None
request_id: str = "-"
class Page(BaseModel, Generic[T]):
items: list[T]
next_cursor: str | None = None
has_more: bool = False
class Cursor(BaseModel):
"""(created_at, id) 复合游标:单用 created_at 会在同毫秒记录上漏数据。"""
created_at: datetime
id: UUID
def ok(data: Any = None, message: str = "ok") -> dict[str, Any]:
return {
"code": int(ErrorCode.OK),
"message": message,
"data": data,
"request_id": request_id_ctx.get(),
}
def encode_cursor(created_at: datetime, row_id: UUID) -> str:
raw = json.dumps({"t": created_at.isoformat(), "i": str(row_id)}, separators=(",", ":"))
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def decode_cursor(cursor: str) -> Cursor:
try:
padded = cursor + "=" * (-len(cursor) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded))
return Cursor(created_at=datetime.fromisoformat(payload["t"]), id=UUID(payload["i"]))
except (binascii.Error, KeyError, ValueError, json.JSONDecodeError) as exc:
raise AppError(ErrorCode.BAD_REQUEST, "分页游标不合法") from exc
def clamp_limit(limit: int) -> int:
if limit <= 0:
return DEFAULT_LIMIT
return min(limit, MAX_LIMIT)
class Pagination(BaseModel):
limit: int = Field(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT)
cursor: str | None = None
def parsed(self) -> tuple[int, Cursor | None]:
return clamp_limit(self.limit), (decode_cursor(self.cursor) if self.cursor else None)
+80
View File
@@ -0,0 +1,80 @@
"""令牌与验证码。
安全约定:
- access token 短期(默认 30 分钟),refresh token 长期(14 天),两者 type 不同,不可互换。
- 令牌里不放手机号等 PII,只放 sub=user_id。
- 验证码只在服务端比对,**不写日志**;开发环境返回固定码仅用于本地联调。
"""
from __future__ import annotations
import hmac
import secrets
import time
import uuid
from dataclasses import dataclass
from typing import Any, Literal
import jwt
from .config import get_settings
from .errors import AppError, ErrorCode
settings = get_settings()
TokenType = Literal["access", "refresh"]
@dataclass(frozen=True)
class TokenPair:
access_token: str
refresh_token: str
expires_in: int
def _new_token(user_id: uuid.UUID, token_type: TokenType, ttl_seconds: int) -> str:
now = int(time.time())
payload: dict[str, Any] = {
"sub": str(user_id),
"typ": token_type,
"iat": now,
"exp": now + ttl_seconds,
"jti": secrets.token_urlsafe(8),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def create_token_pair(user_id: uuid.UUID) -> TokenPair:
access_ttl = settings.access_token_minutes * 60
return TokenPair(
access_token=_new_token(user_id, "access", access_ttl),
refresh_token=_new_token(user_id, "refresh", settings.refresh_token_days * 86400),
expires_in=access_ttl,
)
def decode_token(token: str, expect: TokenType) -> uuid.UUID:
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except jwt.ExpiredSignatureError as exc:
raise AppError(ErrorCode.TOKEN_EXPIRED, "登录已过期,请重新登录") from exc
except jwt.PyJWTError as exc:
raise AppError(ErrorCode.TOKEN_INVALID, "令牌无效") from exc
if payload.get("typ") != expect:
# 用 refresh 当 access 用是常见攻击面,必须拦
raise AppError(ErrorCode.TOKEN_INVALID, "令牌类型不匹配")
try:
return uuid.UUID(str(payload["sub"]))
except (KeyError, ValueError) as exc:
raise AppError(ErrorCode.TOKEN_INVALID, "令牌内容不合法") from exc
def new_invite_code() -> str:
# 去掉容易看错的 0/O/1/I
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
return "".join(secrets.choice(alphabet) for _ in range(8))
def invite_code_matches(provided: str, expected: str) -> bool:
return hmac.compare_digest(provided.strip().upper(), expected)
+111
View File
@@ -0,0 +1,111 @@
"""短生命周期状态存储:验证码、幂等键。
dev/test 用进程内实现(不依赖 Redis 也能跑测试),prod 强制走 Redis。
接口刻意做小,将来换实现不影响调用方。
"""
from __future__ import annotations
import json
import time
from typing import Protocol
from .config import get_settings
settings = get_settings()
class KVStore(Protocol):
async def set(self, key: str, value: str, ttl_seconds: int) -> None: ...
async def get(self, key: str) -> str | None: ...
async def delete(self, key: str) -> None: ...
async def incr(self, key: str, ttl_seconds: int) -> int: ...
class MemoryStore:
"""进程内 TTL 字典。仅限单进程 dev/test——多 worker 下不共享,别在生产用。"""
def __init__(self) -> None:
self._data: dict[str, tuple[str, float]] = {}
def _purge(self, key: str) -> None:
item = self._data.get(key)
if item and item[1] < time.time():
self._data.pop(key, None)
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
self._data[key] = (value, time.time() + ttl_seconds)
async def get(self, key: str) -> str | None:
self._purge(key)
item = self._data.get(key)
return item[0] if item else None
async def delete(self, key: str) -> None:
self._data.pop(key, None)
async def incr(self, key: str, ttl_seconds: int) -> int:
self._purge(key)
current = int(self._data[key][0]) if key in self._data else 0
current += 1
expire_at = self._data[key][1] if key in self._data else time.time() + ttl_seconds
self._data[key] = (str(current), expire_at)
return current
class RedisStore:
def __init__(self, url: str) -> None:
from redis.asyncio import Redis # 延迟导入:dev 环境没有 redis 包也能起
self._client = Redis.from_url(url, decode_responses=True)
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
await self._client.set(key, value, ex=ttl_seconds)
async def get(self, key: str) -> str | None:
return await self._client.get(key)
async def delete(self, key: str) -> None:
await self._client.delete(key)
async def incr(self, key: str, ttl_seconds: int) -> int:
value = await self._client.incr(key)
if value == 1:
await self._client.expire(key, ttl_seconds)
return int(value)
_store: KVStore | None = None
def get_store() -> KVStore:
global _store
if _store is None:
_store = RedisStore(settings.redis_url) if settings.env == "prod" else MemoryStore()
return _store
def reset_store_for_tests() -> None:
"""测试夹具用:清掉进程内状态。"""
global _store
_store = None
def dumps(value: object) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
# ── 业务键约定 ──────────────────────────────────────────
def sms_code_key(phone: str) -> str:
return f"sms:login:{phone}"
def sms_throttle_key(phone: str) -> str:
return f"sms:throttle:{phone}"
def idempotency_key(user_id: str, key: str) -> str:
return f"idem:{user_id}:{key}"
+22
View File
@@ -0,0 +1,22 @@
[tool.ruff]
line-length = 100
target-version = "py310"
extend-exclude = ["alembic/versions"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "C4", "SIM"]
ignore = ["S101"] # 测试里允许 assert
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S105", "S106"] # 测试里的假令牌/假口令
"alembic/env.py" = ["E402"]
[tool.ruff.lint.isort]
known-first-party = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
addopts = "-q --strict-markers"
filterwarnings = ["ignore::DeprecationWarning"]
+15
View File
@@ -0,0 +1,15 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.10.4
pydantic-settings==2.7.0
pyjwt==2.10.1
redis==5.2.1
# dev / test only (split into requirements-dev.txt when it grows)
pytest==8.3.4
pytest-asyncio==0.25.0
httpx==0.28.1
ruff==0.8.4
View File
+98
View File
@@ -0,0 +1,98 @@
"""测试夹具。
约定:
- 一律走真实 HTTP 栈(ASGITransport),不做 handler 级 mock —— 中间件/依赖/RLS 都是被测对象。
- 每个用例前清库,避免串味;清库走**迁移角色**(app 角色没有 TRUNCATE 权限,这是设计使然)。
"""
from __future__ import annotations
import os
os.environ["ENV"] = "test"
os.environ.setdefault("SMS_DEV_CODE", "123456")
import uuid
from collections.abc import AsyncIterator
from typing import Any
import pytest
from alembic import command
from alembic.config import Config
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.db import engine
from app.main import app
from app.stores import reset_store_for_tests
settings = get_settings()
DEV_CODE = settings.sms_dev_code
_TABLES = "audit_log, invitation, member, workspace, app_user"
def _run_migrations() -> None:
cfg = Config(str(os.path.join(os.path.dirname(__file__), "..", "alembic.ini")))
command.upgrade(cfg, "head")
@pytest.fixture(scope="session", autouse=True)
def migrated_database() -> None:
_run_migrations()
@pytest.fixture(autouse=True)
async def clean_database(migrated_database: None) -> AsyncIterator[None]:
owner_engine = create_async_engine(settings.migration_database_url, poolclass=NullPool)
async with owner_engine.begin() as conn:
await conn.execute(text(f"TRUNCATE {_TABLES} CASCADE"))
await owner_engine.dispose()
reset_store_for_tests()
yield
@pytest.fixture(autouse=True)
async def dispose_app_engine() -> AsyncIterator[None]:
yield
await engine.dispose()
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
def auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def unwrap(response: Any) -> Any:
body = response.json()
assert body.get("code") == 0, f"期望成功,实际 {body}"
return body["data"]
async def login(client: AsyncClient, phone: str | None = None) -> dict[str, Any]:
"""发码 + 登录,返回 {token, refresh_token, user_id, phone}。"""
phone = phone or f"138{uuid.uuid4().int % 100_000_000:08d}"
await client.post("/api/v1/auth/sms/send", json={"phone": phone})
data = unwrap(
await client.post("/api/v1/auth/login", json={"phone": phone, "code": DEV_CODE})
)
return {
"token": data["tokens"]["access_token"],
"refresh_token": data["tokens"]["refresh_token"],
"user_id": data["user"]["id"],
"phone": phone,
"headers": auth(data["tokens"]["access_token"]),
}
async def create_workspace(client: AsyncClient, user: dict[str, Any], name: str) -> dict[str, Any]:
return unwrap(await client.post("/api/v1/workspaces", json={"name": name}, headers=user["headers"]))
+88
View File
@@ -0,0 +1,88 @@
"""认证相关。"""
from __future__ import annotations
from httpx import AsyncClient
from .conftest import DEV_CODE, auth, login, unwrap
async def test_login_creates_user_and_me_works(client: AsyncClient) -> None:
user = await login(client, "13800000001")
me = unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
assert me["user"]["phone"] == "138****0001" # 手机号对外必须打码
assert me["workspaces"] == []
async def test_login_is_idempotent_for_same_phone(client: AsyncClient) -> None:
first = await login(client, "13800000002")
second = await login(client, "13800000002")
assert first["user_id"] == second["user_id"]
async def test_wrong_code_rejected(client: AsyncClient) -> None:
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000003"})
resp = await client.post(
"/api/v1/auth/login", json={"phone": "13800000003", "code": "000000"}
)
assert resp.status_code == 400
assert resp.json()["code"] == 2010
async def test_code_can_only_be_used_once(client: AsyncClient) -> None:
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000004"})
ok = await client.post(
"/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}
)
assert ok.status_code == 200
again = await client.post(
"/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}
)
assert again.json()["code"] == 2010
async def test_sms_throttled(client: AsyncClient) -> None:
payload = {"phone": "13800000005"}
assert (await client.post("/api/v1/auth/sms/send", json=payload)).status_code == 200
second = await client.post("/api/v1/auth/sms/send", json=payload)
assert second.json()["code"] == 2011
async def test_refresh_token_cannot_be_used_as_access_token(client: AsyncClient) -> None:
user = await login(client)
resp = await client.get("/api/v1/auth/me", headers=auth(user["refresh_token"]))
assert resp.status_code == 401
assert resp.json()["code"] == 2003
async def test_access_token_cannot_be_used_to_refresh(client: AsyncClient) -> None:
user = await login(client)
resp = await client.post(
"/api/v1/auth/refresh", json={"refresh_token": user["token"]}
)
assert resp.status_code == 401
assert resp.json()["code"] == 2003
async def test_refresh_returns_working_token_pair(client: AsyncClient) -> None:
user = await login(client)
data = unwrap(
await client.post(
"/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]}
)
)
me = await client.get("/api/v1/auth/me", headers=auth(data["access_token"]))
assert me.status_code == 200
async def test_requires_token(client: AsyncClient) -> None:
resp = await client.get("/api/v1/auth/me")
assert resp.status_code == 401
assert resp.json()["code"] == 2001
assert resp.json()["request_id"] != "-"
async def test_unknown_path_returns_envelope(client: AsyncClient) -> None:
resp = await client.get("/api/v1/nope")
assert resp.status_code == 404
assert set(resp.json()) == {"code", "message", "data", "request_id"}
+64
View File
@@ -0,0 +1,64 @@
"""幂等键:客户端网络抖动重试时,不能建出两个工作区。"""
from __future__ import annotations
from httpx import AsyncClient
from .conftest import login
async def test_same_key_returns_first_result(client: AsyncClient) -> None:
user = await login(client)
headers = {**user["headers"], "Idempotency-Key": "k-1"}
payload = {"name": "幂等工作区"}
first = await client.post("/api/v1/workspaces", json=payload, headers=headers)
second = await client.post("/api/v1/workspaces", json=payload, headers=headers)
assert first.status_code == 200
assert first.json()["data"]["id"] == second.json()["data"]["id"]
assert second.headers.get("X-Idempotent-Replay") == "1"
listed = await client.get("/api/v1/workspaces", headers=user["headers"])
assert len(listed.json()["data"]["items"]) == 1
async def test_different_keys_create_different_workspaces(client: AsyncClient) -> None:
user = await login(client)
a = await client.post(
"/api/v1/workspaces",
json={"name": "一号"},
headers={**user["headers"], "Idempotency-Key": "k-a"},
)
b = await client.post(
"/api/v1/workspaces",
json={"name": "二号"},
headers={**user["headers"], "Idempotency-Key": "k-b"},
)
assert a.json()["data"]["id"] != b.json()["data"]["id"]
async def test_errors_are_not_cached(client: AsyncClient) -> None:
user = await login(client)
headers = {**user["headers"], "Idempotency-Key": "k-err"}
bad = await client.post("/api/v1/workspaces", json={"name": ""}, headers=headers)
assert bad.status_code == 422
good = await client.post("/api/v1/workspaces", json={"name": "修好了"}, headers=headers)
assert good.status_code == 200
assert "X-Idempotent-Replay" not in good.headers
async def test_keys_are_scoped_per_user(client: AsyncClient) -> None:
alice = await login(client)
bob = await login(client)
a = await client.post(
"/api/v1/workspaces",
json={"name": "Alice 的"},
headers={**alice["headers"], "Idempotency-Key": "shared"},
)
b = await client.post(
"/api/v1/workspaces",
json={"name": "Bob 的"},
headers={**bob["headers"], "Idempotency-Key": "shared"},
)
assert a.json()["data"]["id"] != b.json()["data"]["id"]
+122
View File
@@ -0,0 +1,122 @@
"""跨租户隔离 —— 本仓库**最重要的测试**(AGENTS.md 2.5)。
三层验证,任何一层失效都算事故:
1. 接口层:A 租户的令牌打不到 B 租户的资源。
2. 数据层:应用角色连库、绑定 A 的上下文,物理上看不到 B 的行(RLS 生效)。
3. 配置层:RLS 确实 ENABLE,且应用角色不是 superuser / BYPASSRLS(否则 RLS 被跳过)。
"""
from __future__ import annotations
import uuid
import pytest
from httpx import AsyncClient
from sqlalchemy import text
from app.db import SessionFactory, bind_tenant, current_app_role
from app.models import TENANT_TABLES
from .conftest import create_workspace, login, unwrap
async def _two_tenants(client: AsyncClient):
alice = await login(client, "13900000001")
bob = await login(client, "13900000002")
ws_alice = await create_workspace(client, alice, "Alice 的团队")
ws_bob = await create_workspace(client, bob, "Bob 的团队")
return alice, bob, ws_alice, ws_bob
async def test_cannot_read_other_workspace(client: AsyncClient) -> None:
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
resp = await client.get(f"/api/v1/workspaces/{ws_b['id']}", headers=alice["headers"])
assert resp.status_code == 403
assert resp.json()["code"] == 3003
async def test_cannot_list_other_workspace_members(client: AsyncClient) -> None:
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
resp = await client.get(
f"/api/v1/workspaces/{ws_b['id']}/members", headers=alice["headers"]
)
assert resp.status_code == 403
async def test_cannot_invite_into_other_workspace(client: AsyncClient) -> None:
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
resp = await client.post(
f"/api/v1/workspaces/{ws_b['id']}/invitations",
json={"role": "member"},
headers=alice["headers"],
)
assert resp.status_code == 403
async def test_my_workspace_list_is_scoped(client: AsyncClient) -> None:
alice, _bob, ws_a, ws_b = await _two_tenants(client)
mine = unwrap(await client.get("/api/v1/workspaces", headers=alice["headers"]))
ids = [w["id"] for w in mine["items"]]
assert ids == [ws_a["id"]]
assert ws_b["id"] not in ids
async def test_rls_hides_rows_at_database_level(client: AsyncClient) -> None:
_alice, _bob, ws_a, ws_b = await _two_tenants(client)
ws_a_id = uuid.UUID(ws_a["id"])
ws_b_id = uuid.UUID(ws_b["id"])
async with SessionFactory() as session:
async with session.begin():
await bind_tenant(session, ws_a_id, None)
visible = (
await session.execute(text("SELECT id FROM workspace"))
).scalars().all()
member_ids = (
await session.execute(text("SELECT workspace_id FROM member"))
).scalars().all()
assert [str(v) for v in visible] == [ws_a["id"]]
assert set(member_ids) == {ws_a_id}
assert ws_b_id not in set(member_ids)
async def test_rls_without_context_returns_nothing(client: AsyncClient) -> None:
await _two_tenants(client)
async with SessionFactory() as session:
async with session.begin():
count = (await session.execute(text("SELECT count(*) FROM workspace"))).scalar_one()
assert count == 0, "未绑定租户上下文时不应看到任何行"
async def test_rls_enabled_on_every_tenant_table(client: AsyncClient) -> None:
async with SessionFactory() as session:
rows = (
await session.execute(
text(
"SELECT relname, relrowsecurity FROM pg_class "
"WHERE relname = ANY(:names) AND relkind = 'r'"
),
{"names": list(TENANT_TABLES)},
)
).all()
status = {name: enabled for name, enabled in rows}
assert set(status) == set(TENANT_TABLES), "有租户表没建出来"
assert all(status.values()), f"未启用 RLS 的表:{[k for k, v in status.items() if not v]}"
async def test_app_role_cannot_bypass_rls(client: AsyncClient) -> None:
async with SessionFactory() as session:
async with session.begin():
role = await current_app_role(session)
assert role["is_super"] is False, "应用角色绝不能是 superuser,否则 RLS 被跳过"
assert role["bypass_rls"] is False, "应用角色不能带 BYPASSRLS"
assert role["read_all"] is False
async def test_app_role_has_no_ddl(client: AsyncClient) -> None:
"""应用角色不能建表——否则一次 SQL 注入就能改结构。"""
async with SessionFactory() as session:
async with session.begin():
with pytest.raises(Exception):
await session.execute(text("CREATE TABLE should_not_exist (id int)"))
+175
View File
@@ -0,0 +1,175 @@
"""工作区 / 成员 / 邀请。"""
from __future__ import annotations
from httpx import AsyncClient
from .conftest import auth, create_workspace, login, unwrap
async def test_create_workspace_makes_creator_owner(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "达道科技")
assert ws["role"] == "owner"
assert ws["plan"] == "free"
assert ws["slug"].startswith("ws-")
async def test_workspace_appears_in_my_list_and_me(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人工作区")
listed = unwrap(await client.get("/api/v1/workspaces", headers=user["headers"]))
assert [w["id"] for w in listed["items"]] == [ws["id"]]
me = unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
assert me["workspaces"][0]["role"] == "owner"
async def test_members_list_contains_owner_with_masked_phone(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "团队A")
page = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/members", headers=user["headers"])
)
assert page["has_more"] is False
member = page["items"][0]
assert member["role"] == "owner"
assert "****" in member["phone"]
async def test_invite_and_join_flow(client: AsyncClient) -> None:
owner = await login(client)
guest = await login(client)
ws = await create_workspace(client, owner, "协作空间")
invite = unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/invitations",
json={"role": "member", "expires_hours": 24},
headers=owner["headers"],
)
)
assert len(invite["code"]) == 8
joined = unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/join",
json={"code": invite["code"].lower()}, # 邀请码大小写不敏感
headers=guest["headers"],
)
)
assert joined["role"] == "member"
page = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/members", headers=guest["headers"])
)
assert {m["role"] for m in page["items"]} == {"owner", "member"}
async def test_invite_code_single_use(client: AsyncClient) -> None:
owner = await login(client)
second = await login(client)
third = await login(client)
ws = await create_workspace(client, owner, "限量空间")
invite = unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/invitations",
json={"role": "member"},
headers=owner["headers"],
)
)
await client.post(
f"/api/v1/workspaces/{ws['id']}/join",
json={"code": invite["code"]},
headers=second["headers"],
)
resp = await client.post(
f"/api/v1/workspaces/{ws['id']}/join",
json={"code": invite["code"]},
headers=third["headers"],
)
assert resp.status_code == 400
assert resp.json()["code"] == 3010
async def test_bad_invite_code_rejected(client: AsyncClient) -> None:
owner = await login(client)
guest = await login(client)
ws = await create_workspace(client, owner, "封闭空间")
resp = await client.post(
f"/api/v1/workspaces/{ws['id']}/join",
json={"code": "ZZZZZZZZ"},
headers=guest["headers"],
)
assert resp.json()["code"] == 3010
async def test_plain_member_cannot_invite(client: AsyncClient) -> None:
owner = await login(client)
member = await login(client)
ws = await create_workspace(client, owner, "权限空间")
invite = unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/invitations",
json={"role": "member"},
headers=owner["headers"],
)
)
await client.post(
f"/api/v1/workspaces/{ws['id']}/join",
json={"code": invite["code"]},
headers=member["headers"],
)
resp = await client.post(
f"/api/v1/workspaces/{ws['id']}/invitations",
json={"role": "member"},
headers=member["headers"],
)
assert resp.status_code == 403
assert resp.json()["code"] == 3004
async def test_only_owner_can_invite_admin(client: AsyncClient) -> None:
owner = await login(client)
admin = await login(client)
ws = await create_workspace(client, owner, "治理空间")
first = unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/invitations",
json={"role": "admin"},
headers=owner["headers"],
)
)
await client.post(
f"/api/v1/workspaces/{ws['id']}/join",
json={"code": first["code"]},
headers=admin["headers"],
)
resp = await client.post(
f"/api/v1/workspaces/{ws['id']}/invitations",
json={"role": "admin"},
headers=admin["headers"],
)
assert resp.json()["code"] == 3004
async def test_duplicate_slug_conflict(client: AsyncClient) -> None:
user = await login(client)
await client.post(
"/api/v1/workspaces", json={"name": "占位", "slug": "taken"}, headers=user["headers"]
)
resp = await client.post(
"/api/v1/workspaces", json={"name": "抢占", "slug": "taken"}, headers=user["headers"]
)
assert resp.status_code == 409
assert resp.json()["code"] == 1009
async def test_requires_auth(client: AsyncClient) -> None:
resp = await client.get("/api/v1/workspaces")
assert resp.status_code == 401
assert auth("bad.token.here") # 保证 helper 可用
+37
View File
@@ -0,0 +1,37 @@
# 本地开发依赖。端口避开默认值(5433/6380),避免和机器上已有实例打架。
name: dadaotask-dev
services:
postgres:
image: pgvector/pgvector:pg16
container_name: dadaotask-pg
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres_dev_pw
POSTGRES_DB: dadaotask
ports:
- "127.0.0.1:5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
# 首次初始化时创建 dadao_owner / dadao_app 两个角色
- ./postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d dadaotask"]
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7-alpine
container_name: dadaotask-redis
ports:
- "127.0.0.1:6380:6379"
command: ["redis-server", "--appendonly", "yes"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
volumes:
pgdata:
+14
View File
@@ -0,0 +1,14 @@
-- 首次初始化脚本(postgres 容器 entrypoint 执行)。
-- 关键点:应用角色 dadao_app **不是** owner、不是 superuser、没有 BYPASSRLS。
-- 否则 PostgreSQL 会直接跳过行级安全,多租户隔离形同虚设。
CREATE ROLE dadao_owner LOGIN PASSWORD 'dadao_dev_pw';
CREATE ROLE dadao_app LOGIN PASSWORD 'dadao_dev_pw';
-- 迁移角色拥有数据库(可以建表),应用角色只能读写数据
ALTER DATABASE dadaotask OWNER TO dadao_owner;
GRANT CONNECT ON DATABASE dadaotask TO dadao_app;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
ALTER SCHEMA public OWNER TO dadao_owner;
GRANT USAGE ON SCHEMA public TO dadao_app;
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>达道Task</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "dadaotask-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { api, authExpired, session } from "./lib/client";
import type { User, Workspace } from "./lib/types";
import { LoginPage } from "./pages/Login";
import { HomePage } from "./pages/Home";
import { WorkspacePage } from "./pages/Workspace";
import { JoinPage } from "./pages/Join";
interface AuthState {
user: User | null;
workspaces: Workspace[];
loading: boolean;
reload: () => Promise<void>;
signOut: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth 必须在 AuthProvider 内使用");
return ctx;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const reload = useCallback(async () => {
if (!session.access) {
setUser(null);
setWorkspaces([]);
setLoading(false);
return;
}
try {
const me = await api.get<{ user: User; workspaces: Workspace[] }>("/auth/me");
setUser(me.user);
setWorkspaces(me.workspaces);
} catch {
setUser(null);
setWorkspaces([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
const onExpired = () => {
setUser(null);
setWorkspaces([]);
navigate("/login", { replace: true });
};
authExpired.addEventListener("expired", onExpired);
return () => authExpired.removeEventListener("expired", onExpired);
}, [reload, navigate]);
const signOut = useCallback(() => {
session.clear();
setUser(null);
setWorkspaces([]);
navigate("/login", { replace: true });
}, [navigate]);
const value = useMemo<AuthState>(
() => ({ user, workspaces, loading, reload, signOut }),
[user, workspaces, loading, reload, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function App() {
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/w/:wsId" element={<WorkspacePage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthProvider>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
export function Layout({ children }: { children: React.ReactNode }) {
const { user, signOut } = useAuth();
const navigate = useNavigate();
return (
<div className="mx-auto flex min-h-full max-w-5xl flex-col px-6 py-6">
<header className="mb-6 flex items-center justify-between">
<Link to="/" className="flex items-baseline gap-2">
<span className="text-xl font-semibold tracking-tight">达道Task</span>
<span className="text-xs text-ink-500">任务 → 工单 → 知识,一条流</span>
</Link>
<div className="flex items-center gap-3 text-sm text-ink-500">
{user && <span>{user.nickname}</span>}
{user ? (
<button className="btn-ghost" onClick={signOut}>
退出
</button>
) : (
<button className="btn-ghost" onClick={() => navigate("/login")}>
登录
</button>
)}
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="mt-8 text-xs text-ink-500">
达道 · 从任务到知识,一条道走到底
</footer>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
height: 100%;
}
body {
@apply bg-ink-50 text-ink-900 antialiased;
}
}
@layer components {
.card {
@apply rounded-xl border border-ink-100 bg-white p-5 shadow-sm;
}
.btn {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium
transition disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-ink-700 text-white hover:bg-ink-900;
}
.btn-ghost {
@apply btn border border-ink-100 bg-white text-ink-700 hover:bg-ink-50;
}
.input {
@apply w-full rounded-lg border border-ink-100 bg-white px-3 py-2 text-sm
outline-none focus:border-ink-500 focus:ring-2 focus:ring-ink-500/20;
}
.label {
@apply mb-1 block text-xs font-medium text-ink-500;
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* 接口客户端。
*
* 三条约定:
* 1. 后端永远返回 {code,message,data,request_id},code !== 0 一律抛出。
* 2. 写请求自动带 Idempotency-Key,网络抖动重试不会重复建数据。
* 3. 401 统一清理本地令牌并回到登录页,不在业务里各写一遍。
*/
const BASE = "/api/v1";
export class ApiError extends Error {
constructor(
readonly code: number,
message: string,
readonly requestId?: string,
) {
super(message);
this.name = "ApiError";
}
}
export interface Tokens {
access_token: string;
refresh_token: string;
expires_in: number;
}
const ACCESS_KEY = "dadao.access";
const REFRESH_KEY = "dadao.refresh";
export const session = {
get access(): string | null {
return localStorage.getItem(ACCESS_KEY);
},
get refresh(): string | null {
return localStorage.getItem(REFRESH_KEY);
},
save(tokens: Tokens) {
localStorage.setItem(ACCESS_KEY, tokens.access_token);
localStorage.setItem(REFRESH_KEY, tokens.refresh_token);
},
clear() {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
},
};
export const authExpired = new EventTarget();
type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
interface RequestOptions {
method?: Method;
body?: unknown;
signal?: AbortSignal;
/** 幂等键;不传则对写请求自动生成 */
idempotencyKey?: string;
}
async function raw<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const method: Method = opts.method ?? "GET";
const headers: Record<string, string> = { Accept: "application/json" };
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
const token = session.access;
if (token) headers.Authorization = `Bearer ${token}`;
const isWrite = method !== "GET";
if (isWrite) {
headers["Idempotency-Key"] = opts.idempotencyKey ?? crypto.randomUUID();
}
const resp = await fetch(`${BASE}${path}`, {
method,
headers,
signal: opts.signal,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
});
let body: { code: number; message: string; data: T; request_id: string } | null = null;
try {
body = await resp.json();
} catch {
body = null;
}
if (!body) {
throw new ApiError(resp.status, `服务返回了非 JSON 响应(HTTP ${resp.status})`);
}
if (!resp.ok || body.code !== 0) {
if (resp.status === 401) {
session.clear();
authExpired.dispatchEvent(new Event("expired"));
}
throw new ApiError(body.code, body.message, body.request_id);
}
return body.data;
}
export const api = {
get: <T>(path: string, signal?: AbortSignal) => raw<T>(path, { signal }),
post: <T>(path: string, body?: unknown) => raw<T>(path, { method: "POST", body }),
patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }),
del: <T>(path: string) => raw<T>(path, { method: "DELETE" }),
};
+40
View File
@@ -0,0 +1,40 @@
export interface User {
id: string;
phone: string;
nickname: string;
created_at?: string;
}
export interface Workspace {
id: string;
name: string;
slug: string;
plan: string;
role: Role;
created_at: string;
}
export type Role = "owner" | "admin" | "member" | "guest";
export interface Member {
id: string;
user_id: string;
role: Role;
status: string;
nickname: string;
phone: string;
joined_at: string;
}
export interface Page<T> {
items: T[];
next_cursor: string | null;
has_more: boolean;
}
export interface Invitation {
id: string;
code: string;
role: Role;
expires_at: string;
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api } from "../lib/client";
import type { Workspace } from "../lib/types";
export function HomePage() {
const { user, workspaces, loading, reload } = useAuth();
const navigate = useNavigate();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!loading && !user) navigate("/login", { replace: true });
}, [loading, user, navigate]);
async function createWorkspace(event: React.FormEvent) {
event.preventDefault();
if (!name.trim()) return;
setBusy(true);
setError(null);
try {
const ws = await api.post<Workspace>("/workspaces", { name: name.trim() });
setName("");
await reload();
navigate(`/w/${ws.id}`);
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建失败");
} finally {
setBusy(false);
}
}
return (
<Layout>
<div className="grid gap-6 md:grid-cols-[1.6fr_1fr]">
<section>
<h2 className="mb-3 text-sm font-medium text-ink-500">我的工作区</h2>
{loading ? (
<p className="text-sm text-ink-500">加载中…</p>
) : workspaces.length === 0 ? (
<div className="card text-sm text-ink-500">
还没有工作区。右侧创建一个,个人用也一样——先把"任务"放进去。
</div>
) : (
<ul className="space-y-3">
{workspaces.map((ws) => (
<li key={ws.id}>
<Link
to={`/w/${ws.id}`}
className="card flex items-center justify-between transition hover:border-ink-500"
>
<div>
<p className="font-medium">{ws.name}</p>
<p className="mt-1 text-xs text-ink-500">
{ws.slug} · {ws.plan === "free" ? "免费版" : ws.plan}
</p>
</div>
<span className="rounded-full bg-ink-100 px-2 py-1 text-xs text-ink-700">
{roleLabel(ws.role)}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
<aside className="card h-fit">
<h2 className="text-sm font-medium">新建工作区</h2>
<form className="mt-3 space-y-3" onSubmit={createWorkspace}>
<input
className="input"
placeholder="例如:达道科技 / 我的事项"
value={name}
maxLength={64}
onChange={(e) => setName(e.target.value)}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button className="btn-primary w-full" disabled={busy || !name.trim()}>
{busy ? "创建中…" : "创建"}
</button>
</form>
<p className="mt-4 border-t border-ink-100 pt-3 text-xs text-ink-500">
收到邀请链接(/join/…)直接打开即可加入。
</p>
</aside>
</div>
</Layout>
);
}
function roleLabel(role: string): string {
return { owner: "负责人", admin: "管理员", member: "成员", guest: "访客" }[role] ?? role;
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api, session } from "../lib/client";
import type { Workspace } from "../lib/types";
/** 邀请链接落地页:/join/:wsId/:code。未登录先去登录,登录后再回来。 */
export function JoinPage() {
const { wsId, code } = useParams<{ wsId: string; code: string }>();
const { user, loading, reload } = useAuth();
const navigate = useNavigate();
const [message, setMessage] = useState("正在加入工作区…");
const [failed, setFailed] = useState(false);
const started = useRef(false);
useEffect(() => {
if (loading) return;
if (!session.access) {
navigate(`/login?next=${encodeURIComponent(`/join/${wsId}/${code}`)}`, { replace: true });
return;
}
if (!user || started.current || !wsId || !code) return;
started.current = true;
void (async () => {
try {
const ws = await api.post<Workspace>(`/workspaces/${wsId}/join`, { code });
await reload();
setMessage(`已加入「${ws.name}」,正在跳转…`);
setTimeout(() => navigate(`/w/${wsId}`, { replace: true }), 600);
} catch (err) {
setFailed(true);
setMessage(err instanceof ApiError ? err.message : "加入失败");
}
})();
}, [loading, user, wsId, code, navigate, reload]);
return (
<Layout>
<div className="mx-auto mt-10 max-w-md">
<div className="card text-center">
<p className={failed ? "text-sm text-red-600" : "text-sm text-ink-700"}>{message}</p>
{failed && (
<button className="btn-ghost mt-4" onClick={() => navigate("/")}>
返回工作区列表
</button>
)}
</div>
</div>
</Layout>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api, session, type Tokens } from "../lib/client";
import type { User } from "../lib/types";
export function LoginPage() {
const { reload } = useAuth();
const navigate = useNavigate();
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [countdown, setCountdown] = useState(0);
useEffect(() => {
if (countdown <= 0) return;
const timer = setTimeout(() => setCountdown((v) => v - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const phoneValid = /^1[3-9]\d{9}$/.test(phone);
async function sendCode() {
setError(null);
setBusy(true);
try {
const data = await api.post<{ dev_code?: string }>("/auth/sms/send", { phone });
setDevCode(data.dev_code ?? null);
setCountdown(60);
} catch (err) {
setError(err instanceof ApiError ? err.message : "发送失败");
} finally {
setBusy(false);
}
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setBusy(true);
try {
const data = await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code });
session.save(data.tokens);
await reload();
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "登录失败");
} finally {
setBusy(false);
}
}
return (
<Layout>
<div className="mx-auto mt-10 max-w-sm">
<div className="card">
<h1 className="text-lg font-semibold">登录 / 注册</h1>
<p className="mt-1 text-sm text-ink-500">手机号验证码登录,未注册会自动创建账号。</p>
<form className="mt-6 space-y-4" onSubmit={submit}>
<div>
<label className="label" htmlFor="phone">
手机号
</label>
<input
id="phone"
className="input"
inputMode="numeric"
placeholder="13800138000"
value={phone}
onChange={(e) => setPhone(e.target.value.trim())}
maxLength={11}
/>
</div>
<div>
<label className="label" htmlFor="code">
验证码
</label>
<div className="flex gap-2">
<input
id="code"
className="input"
inputMode="numeric"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
/>
<button
type="button"
className="btn-ghost shrink-0"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
</div>
</div>
{devCode && (
<p className="rounded-lg bg-accent-500/10 px-3 py-2 text-xs text-accent-600">
开发环境验证码:{devCode}(生产环境会走短信通道,不回显)
</p>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<button className="btn-primary w-full" disabled={busy || !phoneValid || code.length < 4}>
{busy ? "处理中…" : "登录"}
</button>
</form>
</div>
</div>
</Layout>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Layout } from "../components/Layout";
export function PagePlaceholder({ title, hint }: { title: string; hint?: string }) {
return (
<Layout>
<div className="card">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-ink-500">{hint ?? "M1 开发中"}</p>
</div>
</Layout>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api } from "../lib/client";
import type { Invitation, Member, Page, Workspace } from "../lib/types";
const ROLE_LABEL: Record<string, string> = {
owner: "负责人",
admin: "管理员",
member: "成员",
guest: "访客",
};
export function WorkspacePage() {
const { wsId } = useParams<{ wsId: string }>();
const { reload } = useAuth();
const [ws, setWs] = useState<Workspace | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [invitation, setInvitation] = useState<Invitation | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
if (!wsId) return;
try {
const [detail, page] = await Promise.all([
api.get<Workspace>(`/workspaces/${wsId}`),
api.get<Page<Member>>(`/workspaces/${wsId}/members`),
]);
setWs(detail);
setMembers(page.items);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
}, [wsId]);
useEffect(() => {
void load();
}, [load]);
async function createInvite(role: string) {
if (!wsId) return;
setBusy(true);
setError(null);
try {
setInvitation(await api.post<Invitation>(`/workspaces/${wsId}/invitations`, { role }));
await load();
await reload();
} catch (err) {
setError(err instanceof ApiError ? err.message : "生成邀请失败");
} finally {
setBusy(false);
}
}
const canInvite = ws?.role === "owner" || ws?.role === "admin";
return (
<Layout>
<div className="mb-4 flex items-center gap-3 text-sm text-ink-500">
<Link to="/" className="hover:text-ink-900">
← 工作区
</Link>
{ws && <span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs">{ROLE_LABEL[ws.role]}</span>}
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<div className="grid gap-6 md:grid-cols-[1.6fr_1fr]">
<section className="card">
<h1 className="text-lg font-semibold">{ws?.name ?? "加载中…"}</h1>
<p className="mt-1 text-xs text-ink-500">
{ws ? `${ws.slug} · ${ws.plan === "free" ? "免费版" : ws.plan}` : ""}
</p>
<h2 className="mt-6 text-sm font-medium text-ink-500">成员({members.length})</h2>
<ul className="mt-3 divide-y divide-ink-100">
{members.map((m) => (
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="font-medium">{m.nickname || "未命名"}</span>
<span className="ml-2 text-xs text-ink-500">{m.phone}</span>
</div>
<span className="text-xs text-ink-500">{ROLE_LABEL[m.role] ?? m.role}</span>
</li>
))}
</ul>
<div className="mt-8 rounded-lg border border-dashed border-ink-100 p-4 text-sm text-ink-500">
<p className="font-medium text-ink-700">下一步(M1)</p>
<p className="mt-1">
任务列表、看板、工单升级、知识沉淀会依次落在这里。骨架先把身份、租户、成员跑通。
</p>
</div>
</section>
<aside className="card h-fit">
<h2 className="text-sm font-medium">邀请成员</h2>
{canInvite ? (
<div className="mt-3 space-y-3">
<div className="flex gap-2">
<button
className="btn-ghost flex-1"
disabled={busy}
onClick={() => createInvite("member")}
>
邀请成员
</button>
{ws?.role === "owner" && (
<button
className="btn-ghost flex-1"
disabled={busy}
onClick={() => createInvite("admin")}
>
邀请管理员
</button>
)}
</div>
{invitation && (
<div className="rounded-lg bg-ink-50 p-3 text-xs">
<p className="font-medium text-ink-700">邀请码 {invitation.code}</p>
<p className="mt-1 break-all text-ink-500">
{`${window.location.origin}/join/${wsId}/${invitation.code}`}
</p>
<button
className="btn-ghost mt-2 w-full"
onClick={() => {
void navigator.clipboard.writeText(
`${window.location.origin}/join/${wsId}/${invitation.code}`,
);
}}
>
复制邀请链接
</button>
</div>
)}
</div>
) : (
<p className="mt-2 text-xs text-ink-500">只有负责人和管理员能邀请成员。</p>
)}
</aside>
</div>
</Layout>
);
}
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
// 达道:主色偏墨青,克制,不做"任务工具"常见的荧光蓝
ink: {
50: "#f5f7f8",
100: "#e6eaed",
500: "#3f5a68",
700: "#243c48",
900: "#101b21",
},
accent: {
500: "#c2703d",
600: "#a35a2c",
},
},
fontFamily: {
sans: ["Inter", "PingFang SC", "Microsoft YaHei", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// 开发端口固定 5180,接口走代理,避免本地跨域配置漂移
export default defineConfig({
plugins: [react()],
server: {
port: 5180,
strictPort: true,
proxy: {
"/api": {
target: process.env.VITE_API_TARGET ?? "http://127.0.0.1:8100",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: true,
},
});