commit 0c53aa706274f9df4fa7e0299a61aac380387de3 Author: Steven Date: Wed Oct 28 02:38:53 2026 +0800 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5bcabab --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.webp binary +*.ttf binary +*.jar binary +*.zip binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..07ba733 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59d3f38 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/2.Design/DEVELOPMENT.md b/2.Design/DEVELOPMENT.md new file mode 100644 index 0000000..74604cb --- /dev/null +++ b/2.Design/DEVELOPMENT.md @@ -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。 diff --git a/2.Design/NAMING.md b/2.Design/NAMING.md new file mode 100644 index 0000000..b104308 --- /dev/null +++ b/2.Design/NAMING.md @@ -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 里的占位名全部替换掉? diff --git a/2.Design/PRODUCT-PLAN.md b/2.Design/PRODUCT-PLAN.md new file mode 100644 index 0000000..7b8de95 --- /dev/null +++ b/2.Design/PRODUCT-PLAN.md @@ -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**。反着做的项目,两周后演示很炫,用户却没有真实数据可用。 diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md new file mode 100644 index 0000000..6983d10 --- /dev/null +++ b/2.Design/ROADMAP-M0-M1.md @@ -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 +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3399793 --- /dev/null +++ b/AGENTS.md @@ -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 写入)。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a1e035 --- /dev/null +++ b/README.md @@ -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 骨架。 diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..565b317 --- /dev/null +++ b/android/README.md @@ -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//` 已在 Manifest 注册,但落地处理尚未实现。 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..6dc6ef4 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4f9e1bf --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/dadao/task/MainActivity.kt b/android/app/src/main/java/com/dadao/task/MainActivity.kt new file mode 100644 index 0000000..8de8e76 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/MainActivity.kt @@ -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 + }, + ) + } +} diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt new file mode 100644 index 0000000..451e9a4 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -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( + 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 = 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 = 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 = + 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 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>(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") + } + } +} diff --git a/android/app/src/main/java/com/dadao/task/data/TokenStore.kt b/android/app/src/main/java/com/dadao/task/data/TokenStore.kt new file mode 100644 index 0000000..27ce6a4 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/data/TokenStore.kt @@ -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" + } +} diff --git a/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt new file mode 100644 index 0000000..7b334f0 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/HomeScreen.kt @@ -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() } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var showCreate by remember { mutableStateOf(false) } + var invitation by remember { mutableStateOf(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("知道了") } }, + ) + } +} diff --git a/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt b/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt new file mode 100644 index 0000000..93709b4 --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/LoginScreen.kt @@ -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(null) } + var error by remember { mutableStateOf(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), + ) + } +} diff --git a/android/app/src/main/java/com/dadao/task/ui/Theme.kt b/android/app/src/main/java/com/dadao/task/ui/Theme.kt new file mode 100644 index 0000000..f03ddbd --- /dev/null +++ b/android/app/src/main/java/com/dadao/task/ui/Theme.kt @@ -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) +} diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..7412c85 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..bc97459 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + 10.0.2.2 + 127.0.0.1 + localhost + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..70dfb63 --- /dev/null +++ b/android/build.gradle.kts @@ -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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..5de11bc --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c63afaa --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "DadaoTask" +include(":app") diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..1701c88 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..642a96b --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..47fee1d --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..36173ea --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/0001_init.py b/backend/alembic/versions/0001_init.py new file mode 100644 index 0000000..42fb558 --- /dev/null +++ b/backend/alembic/versions/0001_init.py @@ -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();") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..74b6d94 --- /dev/null +++ b/backend/app/api/auth.py @@ -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 + ], + } + ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..3d29b21 --- /dev/null +++ b/backend/app/api/schemas.py @@ -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] diff --git a/backend/app/api/workspaces.py b/backend/app/api/workspaces.py new file mode 100644 index 0000000..7eff2d3 --- /dev/null +++ b/backend/app/api/workspaces.py @@ -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) diff --git a/backend/app/audit.py b/backend/app/audit.py new file mode 100644 index 0000000..23acf32 --- /dev/null +++ b/backend/app/audit.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..b73c5af --- /dev/null +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..0668be8 --- /dev/null +++ b/backend/app/db.py @@ -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 diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..a8650ac --- /dev/null +++ b/backend/app/deps.py @@ -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"))] diff --git a/backend/app/errors.py b/backend/app/errors.py new file mode 100644 index 0000000..697b9f5 --- /dev/null +++ b/backend/app/errors.py @@ -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) diff --git a/backend/app/logging_setup.py b/backend/app/logging_setup.py new file mode 100644 index 0000000..f58b9b7 --- /dev/null +++ b/backend/app/logging_setup.py @@ -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 "" + 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) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e7fde9d --- /dev/null +++ b/backend/app/main.py @@ -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, + } diff --git a/backend/app/middleware.py b/backend/app/middleware.py new file mode 100644 index 0000000..10c1d25 --- /dev/null +++ b/backend/app/middleware.py @@ -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, + } diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..0024584 --- /dev/null +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..a8ff6d9 --- /dev/null +++ b/backend/app/schemas.py @@ -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) diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..c469bca --- /dev/null +++ b/backend/app/security.py @@ -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) diff --git a/backend/app/stores.py b/backend/app/stores.py new file mode 100644 index 0000000..71c729e --- /dev/null +++ b/backend/app/stores.py @@ -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}" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..9b9ddc1 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..07194e7 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..40c9d33 --- /dev/null +++ b/backend/tests/conftest.py @@ -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"])) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..91e259a --- /dev/null +++ b/backend/tests/test_auth.py @@ -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"} diff --git a/backend/tests/test_idempotency.py b/backend/tests/test_idempotency.py new file mode 100644 index 0000000..768d744 --- /dev/null +++ b/backend/tests/test_idempotency.py @@ -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"] diff --git a/backend/tests/test_tenant_isolation.py b/backend/tests/test_tenant_isolation.py new file mode 100644 index 0000000..3f574e3 --- /dev/null +++ b/backend/tests/test_tenant_isolation.py @@ -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)")) diff --git a/backend/tests/test_workspace.py b/backend/tests/test_workspace.py new file mode 100644 index 0000000..7a39b40 --- /dev/null +++ b/backend/tests/test_workspace.py @@ -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 可用 diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..f442e57 --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -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: diff --git a/deploy/postgres/init/01-roles.sql b/deploy/postgres/init/01-roles.sql new file mode 100644 index 0000000..376eb4c --- /dev/null +++ b/deploy/postgres/init/01-roles.sql @@ -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; diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8e18742 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + 达道Task + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..dfe81e3 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..ce817f1 --- /dev/null +++ b/web/src/App.tsx @@ -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; + signOut: () => void; +} + +const AuthContext = createContext(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(null); + const [workspaces, setWorkspaces] = useState([]); + 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( + () => ({ user, workspaces, loading, reload, signOut }), + [user, workspaces, loading, reload, signOut], + ); + + return {children}; +} + +export function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..e89a22b --- /dev/null +++ b/web/src/components/Layout.tsx @@ -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 ( +
+
+ + 达道Task + 任务 → 工单 → 知识,一条流 + +
+ {user && {user.nickname}} + {user ? ( + + ) : ( + + )} +
+
+
{children}
+
+ 达道 · 从任务到知识,一条道走到底 +
+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..cac12d5 --- /dev/null +++ b/web/src/index.css @@ -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; + } +} diff --git a/web/src/lib/client.ts b/web/src/lib/client.ts new file mode 100644 index 0000000..8ccb563 --- /dev/null +++ b/web/src/lib/client.ts @@ -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(path: string, opts: RequestOptions = {}): Promise { + const method: Method = opts.method ?? "GET"; + const headers: Record = { 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: (path: string, signal?: AbortSignal) => raw(path, { signal }), + post: (path: string, body?: unknown) => raw(path, { method: "POST", body }), + patch: (path: string, body?: unknown) => raw(path, { method: "PATCH", body }), + del: (path: string) => raw(path, { method: "DELETE" }), +}; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..684b593 --- /dev/null +++ b/web/src/lib/types.ts @@ -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 { + items: T[]; + next_cursor: string | null; + has_more: boolean; +} + +export interface Invitation { + id: string; + code: string; + role: Role; + expires_at: string; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..203a72a --- /dev/null +++ b/web/src/main.tsx @@ -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( + + + + + , +); diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx new file mode 100644 index 0000000..cc2c941 --- /dev/null +++ b/web/src/pages/Home.tsx @@ -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(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("/workspaces", { name: name.trim() }); + setName(""); + await reload(); + navigate(`/w/${ws.id}`); + } catch (err) { + setError(err instanceof ApiError ? err.message : "创建失败"); + } finally { + setBusy(false); + } + } + + return ( + +
+
+

我的工作区

+ {loading ? ( +

加载中…

+ ) : workspaces.length === 0 ? ( +
+ 还没有工作区。右侧创建一个,个人用也一样——先把"任务"放进去。 +
+ ) : ( +
    + {workspaces.map((ws) => ( +
  • + +
    +

    {ws.name}

    +

    + {ws.slug} · {ws.plan === "free" ? "免费版" : ws.plan} +

    +
    + + {roleLabel(ws.role)} + + +
  • + ))} +
+ )} +
+ + +
+
+ ); +} + +function roleLabel(role: string): string { + return { owner: "负责人", admin: "管理员", member: "成员", guest: "访客" }[role] ?? role; +} diff --git a/web/src/pages/Join.tsx b/web/src/pages/Join.tsx new file mode 100644 index 0000000..2110890 --- /dev/null +++ b/web/src/pages/Join.tsx @@ -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(`/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 ( + +
+
+

{message}

+ {failed && ( + + )} +
+
+
+ ); +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..9fa6162 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -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(null); + const [error, setError] = useState(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 ( + +
+
+

登录 / 注册

+

手机号验证码登录,未注册会自动创建账号。

+ +
+
+ + setPhone(e.target.value.trim())} + maxLength={11} + /> +
+ +
+ +
+ setCode(e.target.value.trim())} + maxLength={6} + /> + +
+
+ + {devCode && ( +

+ 开发环境验证码:{devCode}(生产环境会走短信通道,不回显) +

+ )} + {error &&

{error}

} + + +
+
+
+
+ ); +} diff --git a/web/src/pages/Placeholder.tsx b/web/src/pages/Placeholder.tsx new file mode 100644 index 0000000..d182fd1 --- /dev/null +++ b/web/src/pages/Placeholder.tsx @@ -0,0 +1,12 @@ +import { Layout } from "../components/Layout"; + +export function PagePlaceholder({ title, hint }: { title: string; hint?: string }) { + return ( + +
+

{title}

+

{hint ?? "M1 开发中"}

+
+
+ ); +} diff --git a/web/src/pages/Workspace.tsx b/web/src/pages/Workspace.tsx new file mode 100644 index 0000000..e03c995 --- /dev/null +++ b/web/src/pages/Workspace.tsx @@ -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 = { + owner: "负责人", + admin: "管理员", + member: "成员", + guest: "访客", +}; + +export function WorkspacePage() { + const { wsId } = useParams<{ wsId: string }>(); + const { reload } = useAuth(); + const [ws, setWs] = useState(null); + const [members, setMembers] = useState([]); + const [invitation, setInvitation] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + if (!wsId) return; + try { + const [detail, page] = await Promise.all([ + api.get(`/workspaces/${wsId}`), + api.get>(`/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(`/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 ( + +
+ + ← 工作区 + + {ws && {ROLE_LABEL[ws.role]}} +
+ + {error &&

{error}

} + +
+
+

{ws?.name ?? "加载中…"}

+

+ {ws ? `${ws.slug} · ${ws.plan === "free" ? "免费版" : ws.plan}` : ""} +

+ +

成员({members.length})

+
    + {members.map((m) => ( +
  • +
    + {m.nickname || "未命名"} + {m.phone} +
    + {ROLE_LABEL[m.role] ?? m.role} +
  • + ))} +
+ +
+

下一步(M1)

+

+ 任务列表、看板、工单升级、知识沉淀会依次落在这里。骨架先把身份、租户、成员跑通。 +

+
+
+ + +
+
+ ); +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..1da3232 --- /dev/null +++ b/web/tailwind.config.js @@ -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: [], +}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..bd0ffbe --- /dev/null +++ b/web/tsconfig.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..f7cd326 --- /dev/null +++ b/web/vite.config.ts @@ -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, + }, +});