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:
@@ -0,0 +1,8 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.webp binary
|
||||
*.ttf binary
|
||||
*.jar binary
|
||||
*.zip binary
|
||||
@@ -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
@@ -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*
|
||||
@@ -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。
|
||||
@@ -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 里的占位名全部替换掉?
|
||||
@@ -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**。反着做的项目,两周后演示很炫,用户却没有真实数据可用。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 写入)。
|
||||
@@ -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 骨架。
|
||||
@@ -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 注册,但落地处理尚未实现。
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "DadaoTask"
|
||||
include(":app")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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();")
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]))
|
||||
@@ -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"}
|
||||
@@ -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"]
|
||||
@@ -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)"))
|
||||
@@ -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 可用
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user