diff --git a/Aurask_Technical_Operations_Plan.md b/Aurask_Technical_Operations_Plan.md index 54f986c..4c57954 100644 --- a/Aurask_Technical_Operations_Plan.md +++ b/Aurask_Technical_Operations_Plan.md @@ -1,531 +1,331 @@ -# Aurask 技术与运营方案(当前实现路径版) +# Aurask Technical Operations Plan -> 本文档基于当前仓库实现、`AGENTS.md` 约束与 `deploy/k3s/README.md` 部署规划更新。 -> 核心方向不变:面向海外用户,用 Langflow 承载模板化工作流,用 AnythingLLM 承载知识库/RAG,用 Aurask 自身网关统一做鉴权、套餐、TBU、订单、审计和运维闭环。 +> 更新日期:2026-04-19 +> 本文档已按当前代码实现、目录结构、门户登录流与 DevCloud 默认部署方式同步更新。 -## 0. 当前结论 +## 1. 当前阶段定位 -Aurask 当前已完成 **可运行 MVP 后端骨架**: +Aurask 当前处于 **可运行 MVP / 初版门户** 阶段,已覆盖: -- 使用 Python 模块化单体实现首版领域闭环。 -- 已具备 `Auth + API Gateway`、`Billing + Quota + TBU Ledger`、`Workflow Orchestrator`、`Knowledge Base`、`USDT-TRC20 Payment`、`Audit` 等核心边界。 -- 已按产品职责划分根目录:`api`、`protal`、`manager`、`deploy`。 -- 已补充 PostgreSQL、PGVector、Redis、AnythingLLM、Langflow 的桥接配置与接口契约。 -- 已提供 CLI 演示与标准库 HTTP 网关。 -- 已新增面向 `300` 名月度活跃用户的 `k3s` 部署规划:`deploy/k3s/README.md`。 +- Python 模块化后端 +- 标准库 HTTP Gateway +- 租户、用户、API Key、Session +- `LY SSO` / Google 登录入口 +- 首次登录自动创建独立 workspace +- 配额 / TBU / 订单 / 支付闭环 +- 工作流模板编排 +- AnythingLLM workspace / 文档接入门面 +- Langflow / AnythingLLM iframe 门户嵌入 +- DevCloud 镜像与 `aurask.xyz` 默认路由对齐 -当前阶段定位: +当前版本适合: -- **可以用于本地演示、业务流程验证、接口联调和领域模型迭代。** -- **尚不是生产部署版本。** -- 生产化需要依次补齐 PostgreSQL、队列、真实 Langflow/AnythingLLM 接入、k3s manifests、监控告警、备份恢复和支付风控。 +- 本地演示 +- 接口联调 +- 首版门户交互验证 +- DevCloud 环境持续迭代 -## 1. 项目目标 +当前版本尚未完成: -Aurask 是面向海外个人开发者、学生、独立创业者和小团队的轻量级 AI 数字员工工作流平台。 +- PostgreSQL 正式 Repository 替换 +- Redis Worker / 队列消费者 +- Langflow / AnythingLLM 真正生产代理鉴权 +- 完整 Ingress / NetworkPolicy / TLS / Observability -目标能力: - -- 用户无需自行部署 Langflow / AnythingLLM。 -- 用户通过 Aurask 选择审核过的数字员工模板。 -- 用户可绑定知识库 Workspace,并通过 RAG 支撑业务问答。 -- Aurask 统一管理租户、套餐、TBU、订单、支付、审计和成本。 -- 首期支持 USDT-TRC20 支付,后续接入 Stripe / Paddle / Lemon Squeezy 等合规渠道。 - -## 2. 当前代码实现 - -### 2.1 当前目录结构 +## 2. 当前目录结构 ```text api/ - README.md # API 与外部组件桥接说明 - requests/ - aurask-api.http # 前端到后端请求样例 + README.md aurask/ - __init__.py # 包入口,导出 CLI main - __main__.py # python -m aurask 入口 - app.py # 应用装配与 demo bootstrap - api.py # 标准库 HTTP 网关 - bridge_status.py # 组件桥接状态 - cli.py # aurask demo / aurask serve - repository.py # MVP JSON 持久化 - errors.py # 应用错误类型 - ids.py # ID 与时间工具 - audit.py # 审计事件 - auth.py # 租户、用户、API Key - plans.py # 套餐与商品目录 - billing.py # 订单、订阅、权益发放 - quota.py # TBU 额度账户、预扣、结算、账本 - payments.py # USDT-TRC20 支付匹配 - orchestrator.py # 模板工作流编排与 Langflow Runtime 适配 - knowledge_base.py # AnythingLLM Workspace / 文档接入适配 - bridges/ - config.py # PostgreSQL/PGVector/Redis/AnythingLLM/Langflow 环境配置 - postgres.py # PostgreSQL schema contract - pgvector.py # PGVector tenant-filtered collection contract - redis_bridge.py # Redis queue/cache/idempotency key contract - anythingllm.py # AnythingLLM API bridge - langflow.py # Langflow Runtime bridge + api.py + app.py + auth.py + billing.py + bridges/ + knowledge_base.py + orchestrator.py + payments.py + plans.py + quota.py + repository.py + site_config.py protal/ - index.html # 用户前端使用面板 + index.html main.js styles.css manager/ - index.html # 管理员前端使用面板 + index.html main.js styles.css +deploy/ + k3s/ + README.md + base/ tests/ - test_mvp.py # MVP 核心流程测试 -deploy/k3s/ - README.md # 300 MAU k3s 部署规划 + test_auth_sessions.py + test_bridges.py + test_mvp.py ``` -### 2.2 当前可运行能力 - -命令: - -```bash -uv run aurask demo --reset -uv run aurask serve --reset --host 127.0.0.1 --port 8080 -python -m unittest discover -s tests -v -``` - -公开 API: - -- `GET /health` -- `GET /plans` -- `POST /demo/bootstrap` -- `POST /tenants` - -鉴权 API: - -- `GET /quota` -- `GET /workflow-templates` -- `POST /workspaces` -- `POST /documents` -- `POST /orders` -- `POST /payments/match` -- `POST /workflow-runs` -- `GET /workflow-runs/{run_id}` - -### 2.3 当前 MVP 简化点 - -| 能力 | 当前实现 | 生产目标 | -| --- | --- | --- | -| 持久化 | 本地 JSON 文件 | PostgreSQL + PGVector | -| 队列 | 同进程同步执行 | Redis / RabbitMQ / NATS 队列 | -| Langflow | 默认模拟适配层,可用 `LangflowBridge` 接真实服务 | 内部 `ClusterIP` Langflow Runtime Pool | -| AnythingLLM | 默认模拟适配层,可用 `AnythingLLMBridge` 接真实服务 | 内部 `ClusterIP` AnythingLLM API | -| 网关 | Python 标准库 HTTP Server | FastAPI / ASGI + Ingress + HPA | -| 支付 | 人工提交 tx hash 匹配 | TronGrid / Tronscan / 自建节点监听 | -| 观测 | 审计事件写入 store | Prometheus + Loki + Grafana + Alertmanager | -| 部署 | 本地运行 | k3s:`aurask-api` + `aurask-worker` | - -### 2.4 根目录职责 - -| 根目录 | 职责 | -| --- | --- | -| `api` | 后端服务、外部组件桥接配置、前端请求契约 | -| `protal` | 用户前端使用面板。目录名按当前需求使用 `protal` 拼写 | -| `manager` | 管理员前端使用面板 | -| `deploy` | k3s 与后续部署配置 | - -### 2.5 外部组件桥接 - -生产桥接通过环境变量启用: - -```bash -AURASK_USE_EXTERNAL_BRIDGES=true -AURASK_DATABASE_URL=postgresql://aurask:secret@postgres:5432/aurask -AURASK_REDIS_URL=redis://redis:6379/0 -AURASK_ANYTHINGLLM_BASE_URL=http://anythingllm.aurask-runtime.svc.cluster.local:3001 -AURASK_ANYTHINGLLM_API_KEY= -AURASK_LANGFLOW_BASE_URL=http://langflow-runtime.aurask-runtime.svc.cluster.local:7860 -AURASK_LANGFLOW_API_KEY= -``` - -桥接模块: - -- `api/aurask/bridges/postgres.py`:PostgreSQL schema contract。 -- `api/aurask/bridges/pgvector.py`:PGVector collection 与租户过滤契约。 -- `api/aurask/bridges/redis_bridge.py`:队列、缓存、幂等键、限流键约定。 -- `api/aurask/bridges/anythingllm.py`:AnythingLLM Workspace / 文档入库桥接。 -- `api/aurask/bridges/langflow.py`:Langflow 安全模板运行桥接。 - -管理员可通过鉴权接口查看桥接配置状态: - -- `GET /admin/bridge-status` - -## 3. 目标技术架构 - -```text -海外用户 - ↓ -Aurask Web / 用户中心 / 支付页 - ↓ -Auth + API Gateway(鉴权、限流、套餐、TBU 预扣) - ↓ -Billing & Quota Service(订单、余额、TBU、栏位、存储) - ↓ -Workflow Orchestrator(模板、任务队列、运行状态) - ├─ Langflow Runtime Pool(模板工作流执行) - ├─ AnythingLLM API(Workspace、文档、RAG、聊天) - ├─ LLM Proxy(模型路由、Token 计量、成本归集) - └─ PostgreSQL / Redis / Object Storage / Vector DB - ↓ -Observability(日志、指标、审计、告警、成本报表) -``` - -核心原则: - -- **Aurask 网关是唯一公网入口。** -- `Langflow`、`AnythingLLM`、数据库、向量库、Redis 不直接暴露公网。 -- 所有核心实体默认携带 `tenant_id`。 -- 所有用户侧 Token 统一记为 `TBU`。 -- 工作流执行前预扣 TBU,执行后按实际消耗结算。 -- 基础用户只运行审核过的模板,不开放任意代码执行。 - -## 4. 多租户与安全隔离 - -### 4.1 领域标识 - -核心字段: - -- `tenant_id` -- `user_id` -- `workspace_id` -- `flow_id` -- `plan_id` -- `order_id` - -要求: - -- 查询、写入、缓存、对象存储路径、向量检索都必须带租户维度。 -- 不允许只按主键查询而不校验 `tenant_id`。 -- 高付费独立空间可以独立 Namespace / Pod / 数据库凭证,但领域模型保持一致。 - -### 4.2 Langflow 安全边界 - -Langflow 只作为工作流编排/执行引擎,不作为多租户安全边界。 - -基础套餐: - -- 只允许模板化工作流。 -- 用户不进入 Langflow 全功能 UI。 -- 禁止任意 Python 执行。 -- 禁止任意自定义组件。 -- 禁止无白名单外部网络调用。 - -生产建议配置: - -- `LANGFLOW_AUTO_LOGIN=False` -- `LANGFLOW_FALLBACK_TO_ENV_VAR=False` -- `LANGFLOW_SECRET_KEY` 使用高强度随机值并定期轮换。 -- `LANGFLOW_DATABASE_URL` 指向 PostgreSQL。 -- Runtime Pod 设置 CPU、内存、pids、超时、临时目录和 egress 白名单。 - -### 4.3 AnythingLLM 边界 - -AnythingLLM 负责 Workspace、文档、RAG 和聊天历史。 - -要求: - -- Workspace 与 Aurask `tenant_id` 绑定。 -- 普通用户只能访问被显式授权的 Workspace。 -- 文档必须先经过 Aurask 上传入口,再转发给 AnythingLLM。 -- 上传链路预留大小、类型、病毒扫描、敏感内容检测钩子。 -- 向量 metadata 必须包含 `tenant_id` 与 `workspace_id`。 - -## 5. 套餐、资源与 TBU - -### 5.1 套餐口径 - -| 套餐/资源 | 价格 | 权益 | 限制 | -| --- | ---: | --- | --- | -| 免费体验 | 0 | 1 个模板工作流、1 个知识库、512MB、7 天 | 限速、低并发、不可调用高成本模型 | -| 基础套餐 | 20 USDT/月 | 3 个工作流、3 个知识库、1GB、900 TBU/月 | 仅模板/受控组件,超额需购买 TBU | -| 新增工作流栏位 | 20 USDT/个/月 | +1 工作流、+1 知识库、+1GB | 不额外赠送 TBU | -| TBU 加购 | 0.15 元/TBU 起 | 1000 TBU 送 100;2000 TBU 送 250 | 赠送额度有效期需明确 | -| 高付费独立空间 | 99 USDT/月起或定制报价 | 独立容器/命名空间、更高并发、更大存储 | 保留 egress 白名单与审计 | - -### 5.2 TBU 计量流程 - -1. 用户发起工作流。 -2. Orchestrator 按模板、输入长度、RAG TopK、预计输出长度估算 TBU。 -3. Quota Service 冻结预计 TBU。 -4. 余额不足则拒绝执行或提示充值/降级模型。 -5. Runtime 执行工作流。 -6. 执行成功后按实际 TBU 结算,释放未用额度。 -7. 执行失败时: - - 供应商未计费:全额释放。 - - 供应商已计费:按实际消耗扣减并记录失败原因。 - -### 5.3 当前代码对应 - -- 套餐定义:`api/aurask/plans.py` -- 额度账户:`api/aurask/quota.py` -- 订单与权益:`api/aurask/billing.py` -- 使用记录:`api/aurask/orchestrator.py` - -## 6. 支付与订单闭环 - -首期使用 `USDT-TRC20`,但必须围绕“可审计订单”设计。 - -最低闭环: - -1. 用户选择套餐或 TBU 包。 -2. 系统生成订单号、金额、链、收款地址、过期时间。 -3. 用户转账。 -4. 系统记录 `tx_hash`、金额、确认数。 -5. 金额与订单匹配。 -6. 达到确认数后开通权益。 -7. 异常金额、重复交易、超时到账进入人工处理队列。 - -当前代码: - -- 订单生成:`BillingService.create_order` -- 支付匹配:`PaymentService.match_trc20_payment` -- 权益发放:`BillingService.fulfill_order` - -注意: - -- TRC20 手续费由 TRON Bandwidth / Energy / TRX 资源模型决定,不是 BTC 手续费。 -- 生产环境必须接入链上监听与幂等校验。 -- 需要保留退款、AML、税务与异常处理字段。 - -## 7. 数据与存储 - -### 7.1 MVP - -当前 MVP 使用 `JsonStore`: - -- 优点:无依赖、便于演示、可快速验证领域流程。 -- 限制:不适合生产、无并发事务、无查询能力、无数据库级隔离。 - -### 7.2 生产目标 - -| 类型 | 推荐 | -| --- | --- | -| 主数据库 | PostgreSQL | -| 向量检索 | PGVector 起步,后续 Qdrant / Weaviate | -| 缓存与队列 | Redis 起步,后续按规模引入 RabbitMQ / NATS | -| 对象存储 | 外部 S3 兼容存储 | -| 备份 | PostgreSQL WAL 归档 + 对象存储备份 | - -数据要求: - -- 核心表必须包含 `tenant_id`。 -- 对象路径遵循 `tenant_id/workspace_id/document_id`。 -- 审计不记录完整 Prompt、完整文档正文和原始密钥。 - -## 8. k3s 生产部署规划 - -详细方案见:`deploy/k3s/README.md`。 - -### 8.1 300 MAU 标准 - -容量假设: - -- 月度活跃付费用户:`300` -- 日活高峰:`40-80` -- 同时在线峰值:`15-30` -- 同时工作流执行峰值:`10-20` -- 外部模型 API,不在集群内自建 GPU 推理。 - -推荐拓扑: - -| 角色 | 数量 | 建议配置 | -| --- | ---: | --- | -| Public LB | 1 | 云 LB 或 `HAProxy/Keepalived` | -| k3s server | 3 | `4 vCPU / 8GB RAM / 120GB SSD` | -| General worker | 2 | `8 vCPU / 16GB RAM / 200GB SSD` | -| AI/runtime worker | 2 | `8 vCPU / 16GB RAM / 250GB SSD` | - -### 8.2 工作负载 - -| 工作负载 | 副本 | 说明 | -| --- | ---: | --- | -| `aurask-api` | 3 | 网关、鉴权、订单、额度查询 | -| `aurask-worker` | 3 | 工作流编排、异步任务、支付匹配 | -| `aurask-cron` | 1 | 周期任务、过期订单、报表 | -| `langflow-runtime` | 3 | 审核模板执行 | -| `anythingllm` | 2 | Workspace、文档、RAG | -| PostgreSQL / PGVector | 3 | CloudNativePG | -| Redis | 2-3 | 队列、缓存、限流 | - -### 8.3 生产化要求 - -- Traefik / Ingress 只暴露 Aurask Web/API。 -- cert-manager 管理 TLS。 -- Longhorn 或云块存储承载 PVC。 -- PostgreSQL 备份到外部 S3。 -- Prometheus、Grafana、Loki、Alertmanager 提供观测。 -- NetworkPolicy 默认拒绝,按服务链路放通。 - -## 9. 运维与可观测性 - -必须观测: - -- API QPS、延迟、错误率。 -- 工作流运行数、失败率、排队时长。 -- TBU 预扣、消耗、释放、退款。 -- 订单创建、支付匹配、异常订单。 -- PostgreSQL 连接数、复制延迟、磁盘。 -- Redis 内存、队列长度、命中率。 -- Langflow / AnythingLLM 请求耗时和失败率。 - -告警优先级: - -- `P1`:API 不可用、数据库不可写、支付匹配失败。 -- `P2`:工作流失败率升高、队列堆积、文档入库堆积。 -- `P3`:磁盘使用率高、备份失败、证书续期异常。 - -## 10. 运营方案 - -### 10.1 定位 - -面向海外小团队的低门槛 AI 数字员工平台: - -- 不用自建 Langflow / AnythingLLM。 -- 内置客服、知识库问答、邮件助理、表格处理、社媒内容等模板。 -- 统一管理知识库、工作流、TBU 和支付。 -- 默认英文 UI,后续多语言。 - -### 10.2 获客 - -优先渠道: - -- GitHub 模板工作流与 SDK 示例。 -- Medium / Dev.to 英文教程。 -- Product Hunt / BetaList。 -- Reddit / Discord / Telegram 圈层运营。 -- Google Search 小词与 AI 工具导航站赞助。 - -冷启动预算建议: - -- 内容与社群优先。 -- 付费投放 `3000-8000 元/月` 起步。 -- 每周复盘 CAC、激活率、付费率、退款率。 - -### 10.3 激活 - -首个 10 分钟体验路径: - -1. 注册。 -2. 选择模板。 -3. 创建 Workspace。 -4. 上传样例文档或使用示例知识库。 -5. 运行一次模板工作流。 -6. 看到 TBU 消耗、节省时间和下一步建议。 - -## 11. 财务测算口径 - -基准假设仍沿用原方案,但与 TBU 口径对齐: - -| 项目 | 基准 | -| --- | --- | -| 付费用户 | 300 | -| 基础套餐 | 20 USDT/月 | -| 新增栏位 | 160 个/月 | -| TBU 加购 | 60 人/月,平均 800 TBU | -| 汇率 | 1 USDT ≈ 6.86 元 | -| TBU 成本 | 供应商侧 0.06 元/单位,用户侧 1 TBU = 0.8 供应商单位 | - -收入: - -| 收入项 | 公式 | 金额 | -| --- | ---: | ---: | -| 基础套餐 | `300 × 20 × 6.86` | 41,160 元 | -| 新增栏位 | `160 × 20 × 6.86` | 21,952 元 | -| TBU 加购 | `60 × 800 × 0.15` | 7,200 元 | -| 月营收 | | 70,312 元 | - -成本: - -| 成本项 | 金额 | -| --- | ---: | -| 基础套餐 TBU 成本 | 12,960 元 | -| 加购 TBU 成本 | 2,304 元 | -| 基础设施 | 1,500-6,000 元,视 VPS/k3s/云资源而定 | -| 运营获客 | 6,000 元起 | -| 其他工具与支付预留 | 1,100 元起 | - 说明: -- 年付用户不能重复计入 MRR。 -- 新增栏位不赠送 TBU。 -- k3s 高可用部署成本通常高于单机 VPS,财务模型要单独列“稳定性成本”。 +- `protal/` 目录拼写继续保持既定要求 +- `api/` 负责后端、桥接与门户配置 +- `deploy/k3s/base/` 现已开始保存基础部署清单 -## 12. 落地路线图 +## 3. 当前技术架构 -### Phase 0:已完成 +### 3.1 用户访问路径 -- Python 模块化单体。 -- CLI demo。 -- HTTP API。 -- TBU 预扣与结算。 -- Workspace 与租户绑定。 -- USDT 订单与支付匹配骨架。 -- MVP 测试。 -- k3s 300 MAU 部署规划。 +```text +Browser + -> aurask.xyz/signin + -> Aurask Web Protal + -> Aurask API Gateway + -> Session / Quota / Workflow / Knowledge services + -> Langflow / AnythingLLM / PostgreSQL / Redis +``` -### Phase 1:生产数据层 +### 3.2 门户结构 -- 将 `JsonStore` 替换为 PostgreSQL repository。 -- 引入数据库迁移。 -- 增加 Redis 队列与 worker。 -- 补齐幂等键与事务边界。 +未登录: -### Phase 2:真实服务接入 +- 渲染 `/signin` +- 提供 `Use LY SSO` +- 提供 `Continue with Google` -- 接真实 Langflow Runtime Pool。 -- 接真实 AnythingLLM API。 -- 接 LLM Proxy。 -- 接 TronGrid / Tronscan 支付监听。 +已登录: -### Phase 3:k3s 部署 +- `Workflows` 页签:内嵌 Langflow +- `Knowledge Base` 页签:内嵌 AnythingLLM +- 右上角个人中心:展示用户、租户、workspace、套餐与退出登录 -- 编写 `deploy/k3s/base` manifests。 -- 拆分 `aurask-api` 与 `aurask-worker`。 -- 部署 PostgreSQL、Redis、Ingress、Secret、NetworkPolicy。 -- 接入 Prometheus / Loki / Grafana / Alertmanager。 +## 4. 登录与身份模型 -### Phase 4:商业化 Beta +### 4.1 登录方式 -- 英文 UI。 -- 模板库。 -- 支付异常后台。 -- 周报与留存邮件。 -- 用户协议、退款规则、AML 记录。 +当前支持: -## 13. 验收标准 +1. `LY SSO` + - 以按钮形式进入 + - 当前实现为配置驱动的本地接入层 + - 通过 `AURASK_LY_SSO_USERNAME` / `AURASK_LY_SSO_PASSWORD` 控制 +2. Google + - 通过 `AURASK_GOOGLE_CLIENT_ID` 开启 + - 首次注册后自动开通独立 tenant + user + workspace -MVP 验收: +### 4.2 Token 模型 -- `python -m unittest discover -s tests -v` 通过。 -- `uv run aurask demo --reset` 可创建租户、分配套餐、创建 Workspace、运行模板工作流。 -- 余额不足时阻止工作流执行。 -- TBU 加购支付后能发放额度。 -- 文档上传能按租户与 Workspace 记录路径。 +当前保留两类 Bearer Token: -300 MAU 生产验收: +- `API Key` + - 面向接口调用 +- `Session Token` + - 面向门户登录态 -- API P95 延迟 `< 500ms`,不含外部模型调用。 -- 并发工作流 `10-20` 稳定运行。 -- 工作流成功率 `95%+`。 -- 支付匹配成功率 `99%+`。 -- PostgreSQL 备份可恢复。 -- Langflow / AnythingLLM 不暴露公网。 -- NetworkPolicy 默认拒绝并按链路放通。 +### 4.3 首次登录自动开通 -## 14. 参考文档 +对新用户执行: + +1. 创建 tenant +2. 创建 owner user +3. 关联外部身份 +4. 开通 `free_trial` +5. 创建默认 `Personal Workspace` +6. 签发 session token + +## 5. 核心模块现状 + +### `auth.py` + +当前负责: + +- tenant / user 创建 +- API Key 认证 +- external identity 绑定 +- session 创建、校验、吊销 + +### `site_config.py` + +当前负责: + +- 公网域名 +- API 地址 +- Langflow / AnythingLLM 嵌入地址 +- `LY SSO` / Google 开关 +- DevCloud 镜像 / NodePort 默认值 + +### `knowledge_base.py` + +当前负责: + +- workspace 创建 +- 文档元数据接入 +- tenant 维度隔离 + +### `orchestrator.py` + +当前负责: + +- 审核模板执行 +- TBU 预扣 / 结算 +- workspace 绑定校验 + +## 6. 当前接口规划 + +### 6.1 公开接口 + +```text +GET /health +GET /plans +GET /auth/config +POST /auth/ly-sso/login +POST /auth/google/login +POST /demo/bootstrap +POST /tenants +``` + +### 6.2 登录后接口 + +```text +GET /auth/session +POST /auth/logout +GET /me +GET /quota +GET /workflow-templates +GET /workspaces +POST /workspaces +POST /documents +POST /orders +POST /payments/match +POST /workflow-runs +GET /workflow-runs/{run_id} +GET /admin/bridge-status +``` + +## 7. DevCloud 对齐结果 + +根据 `devcloud` 当前仓库,Aurask 已知线上默认值如下: + +- API 镜像:`registry.mydevcloud.love/devcloud/aurask-api:latest` +- Web 镜像:`registry.mydevcloud.love/devcloud/aurask-web:latest` +- API NodePort:`30091` +- Web NodePort:`30090` +- 线上 API 入口:`https://aurask.xyz/api` +- 线上 Web 入口:`https://aurask.xyz` + +这些值已经体现在: + +- `api/aurask/site_config.py` +- `deploy/k3s/base/aurask-config.yaml` +- `deploy/k3s/base/aurask-api.yaml` +- `deploy/k3s/base/aurask-web.yaml` + +## 8. k3s 首版部署路径 + +### 8.1 当前已落地清单 + +```text +deploy/k3s/base/namespace.yaml +deploy/k3s/base/aurask-config.yaml +deploy/k3s/base/secrets.example.yaml +deploy/k3s/base/aurask-api.yaml +deploy/k3s/base/aurask-web.yaml +deploy/k3s/base/kustomization.yaml +``` + +### 8.2 首版目标 + +按 `300 MAU` 规划: + +- `3` 台 k3s server +- `2` 台 general worker +- `2` 台 runtime worker + +基础服务: + +- `aurask-api` +- `aurask-web` +- PostgreSQL + PGVector +- Redis +- Langflow +- AnythingLLM + +## 9. 运行时与嵌入要求 + +### 9.1 Langflow + +当前门户要求: + +- 在登录后以 iframe 嵌入 +- 地址由 `AURASK_PUBLIC_LANGFLOW_URL` 控制 + +后续生产建议: + +- 使用 Aurask 网关代理转发 +- 加入 session 校验 +- 只暴露审核过的模板能力 + +### 9.2 AnythingLLM + +当前门户要求: + +- 在登录后以 iframe 嵌入 +- 地址由 `AURASK_PUBLIC_ANYTHINGLLM_URL` 控制 + +后续生产建议: + +- 由 Aurask 代理 workspace 身份 +- 不对终端用户暴露全局管理后台 + +## 10. 安全与隔离原则 + +当前仍坚持以下边界: + +- 所有核心实体保留 `tenant_id` +- workspace 必须绑定 tenant +- workflow 执行前必须预扣 TBU +- 文档接入路径保留 `tenant_id/workspace_id/document_id` +- 密码、API Key、OAuth client 不写入 Git + +针对当前 iframe 方案,后续必须补强: + +- Langflow / AnythingLLM 代理鉴权 +- session 到 runtime 的信任链 +- CSP / X-Frame-Options / SameSite 策略 + +## 11. 验证状态 + +当前已通过: + +```text +python -m unittest discover -s tests -v +``` + +已覆盖: + +- MVP 核心业务闭环 +- 桥接配置契约 +- `LY SSO` 登录 +- Google 首次登录建 workspace +- Session 认证 + +## 12. 下一阶段实施顺序 + +建议按以下顺序继续推进: + +1. 用 PostgreSQL Repository 替换 `JsonStore` +2. 引入 Redis 队列与 `aurask-worker` +3. 为 Langflow / AnythingLLM 增加网关代理层 +4. 补齐 `deploy/k3s/base` 的 runtime / data / ingress / secrets 清单 +5. 增加观测与告警 +6. 增加支付风控与异常订单后台 + +## 13. 参考文件 -- `AGENTS.md` - `README.md` +- `api/README.md` +- `protal/README.md` - `deploy/k3s/README.md` -- k3s HA: -- Longhorn: -- cert-manager: -- CloudNativePG: -- AnythingLLM System Requirements: -- Langflow Security: +- `tests/test_auth_sessions.py` diff --git a/README.md b/README.md index 6cb648e..4df0061 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,60 @@ ## Aurask -Aurask 首版已按 `Aurask_Technical_Operations_Plan.md` 落地为一个可运行的模块化单体后端,并按产品边界拆分为 `api`、`protal`、`manager`、`deploy` 四个根目录。 +Aurask 当前是一个按产品边界拆分的首版实现,根目录保持为四个主要目录: -- `Auth + API Gateway` -- `Billing + Quota + TBU Ledger` -- `Workflow Orchestrator` -- `Langflow` 模板化运行适配层 -- `AnythingLLM` Workspace / 文档接入适配层 -- `USDT-TRC20` 订单与支付匹配 -- `Audit / Usage / Observability` 基础留痕 +- `api/`:后端网关、会话登录、配额/TBU、工作流、知识库桥接 +- `protal/`:用户门户,保留既定目录拼写 +- `manager/`:管理员面板 +- `deploy/`:k3s 与 DevCloud 部署配置 -当前实现是 **MVP 版本**: +当前版本已经补齐了用户门户登录闭环: -- 使用本地 JSON 文件持久化,便于开发和演示 -- 保留了租户、订单、额度、模板、知识库、支付等核心领域边界 -- 后续可以自然迁移到 PostgreSQL、任务队列、Runtime Pool 和真实外部服务 +- `/signin` 风格化登录页 +- `LY SSO` 登录入口 +- Google 首次注册/登录后自动创建用户独立 workspace +- 登录后双标签页工作台: + - `Workflows` 内嵌 `Langflow` + - `Knowledge Base` 内嵌 `AnythingLLM` +- 右上角个人中心与登出 -### Quick start +## 本地运行 + +启动 API: + +```powershell +$env:PYTHONPATH='api' +py -3 -m aurask serve --reset --host 127.0.0.1 --port 8080 +``` 运行演示: -```bash -uv run aurask demo --reset -``` - -启动本地网关: - -```bash -uv run aurask serve --reset --host 127.0.0.1 --port 8080 -``` - -如果本机未安装 `uv`,可用: - ```powershell $env:PYTHONPATH='api' py -3 -m aurask demo --reset -py -3 -m aurask serve --reset --host 127.0.0.1 --port 8080 ``` -### Demo flow +门户静态文件位于 `protal/`,部署到静态 Web 服务后即可使用。开发时可直接由本地静态服务器打开 `protal/index.html`。 -`aurask demo` 会自动完成: - -1. 创建租户与 owner 用户 -2. 分配基础套餐 -3. 创建默认知识库 Workspace -4. 执行一个安全模板工作流 -5. 输出 API Key、工作流结果和剩余额度 - -### HTTP API +## 当前接口 公开接口: - `GET /health` - `GET /plans` +- `GET /auth/config` +- `POST /auth/ly-sso/login` +- `POST /auth/google/login` - `POST /demo/bootstrap` - `POST /tenants` 鉴权后接口: +- `GET /auth/session` +- `POST /auth/logout` +- `GET /me` - `GET /quota` - `GET /workflow-templates` +- `GET /workspaces` - `POST /workspaces` - `POST /documents` - `POST /orders` @@ -69,72 +63,72 @@ py -3 -m aurask serve --reset --host 127.0.0.1 --port 8080 - `GET /workflow-runs/{run_id}` - `GET /admin/bridge-status` -鉴权方式: +Aurask 现在同时支持两类 Bearer Token: -```http -Authorization: Bearer +- API Key:用于原有 API/集成访问 +- Session Token:用于门户登录态 + +## 关键环境变量 + +门户与登录: + +```text +AURASK_PUBLIC_BASE_URL=https://aurask.xyz +AURASK_PUBLIC_API_BASE_URL=https://aurask.xyz/api +AURASK_PUBLIC_LANGFLOW_URL=https://aurask.xyz/runtime/langflow/ +AURASK_PUBLIC_ANYTHINGLLM_URL=https://aurask.xyz/runtime/anythingllm/ +AURASK_LY_SSO_ENABLED=true +AURASK_LY_SSO_USERNAME=ly-xujian1 +AURASK_LY_SSO_PASSWORD= +AURASK_GOOGLE_ENABLED=true +AURASK_GOOGLE_CLIENT_ID= +AURASK_SESSION_TTL_DAYS=7 ``` -### Example +运行时桥接: -1. 创建演示租户: - -```bash -curl -X POST http://127.0.0.1:8080/demo/bootstrap ^ - -H "Content-Type: application/json" ^ - -d "{}" +```text +AURASK_USE_EXTERNAL_BRIDGES=true +AURASK_DATABASE_URL=postgresql://aurask:@postgres.aurask-data.svc.cluster.local:5432/aurask +AURASK_REDIS_URL=redis://redis.aurask-data.svc.cluster.local:6379/0 +AURASK_ANYTHINGLLM_BASE_URL=http://anythingllm.aurask-runtime.svc.cluster.local:3001 +AURASK_ANYTHINGLLM_API_KEY= +AURASK_LANGFLOW_BASE_URL=http://langflow-runtime.aurask-runtime.svc.cluster.local:7860 +AURASK_LANGFLOW_API_KEY= ``` -2. 使用返回的 `api_key` 查询模板: +## DevCloud 默认值 -```bash -curl http://127.0.0.1:8080/workflow-templates ^ - -H "Authorization: Bearer " +已按 `devcloud` 仓库当前配置对齐默认镜像与路由: + +- API 镜像:`registry.mydevcloud.love/devcloud/aurask-api:latest` +- Web 镜像:`registry.mydevcloud.love/devcloud/aurask-web:latest` +- `https://aurask.xyz/api/*` → `45.113.2.55:30091` +- `https://aurask.xyz/*` → `45.113.2.55:30090` + +对应的基础 k3s 清单位于: + +- `deploy/k3s/base/aurask-api.yaml` +- `deploy/k3s/base/aurask-web.yaml` +- `deploy/k3s/base/aurask-config.yaml` +- `deploy/k3s/base/secrets.example.yaml` + +## 测试 + +```powershell +$env:PYTHONPATH='api' +cmd /c "py -3 -m unittest discover -s tests -v" ``` -3. 创建 Workspace: +当前已覆盖: -```bash -curl -X POST http://127.0.0.1:8080/workspaces ^ - -H "Authorization: Bearer " ^ - -H "Content-Type: application/json" ^ - -d "{\"name\":\"Support KB\"}" -``` +- MVP 业务闭环 +- PostgreSQL / PGVector / Redis / AnythingLLM / Langflow 桥接契约 +- `LY SSO` / Google 登录与 session -4. 运行模板工作流: +## 相关文档 -```bash -curl -X POST http://127.0.0.1:8080/workflow-runs ^ - -H "Authorization: Bearer " ^ - -H "Content-Type: application/json" ^ - -d "{\"template_id\":\"tpl_email_assistant\",\"inputs\":{\"topic\":\"refund policy reply\"}}" -``` - -### Testing - -```bash -python -m unittest discover -s tests -v -``` - -### Deployment - -- `deploy/k3s/README.md`: 面向 `300` 名月度活跃用户的 `k3s` 部署方案 -- `api/README.md`: PostgreSQL、PGVector、Redis、AnythingLLM、Langflow 桥接配置 -- `api/requests/aurask-api.http`: 前端到后端请求样例 - -### Project layout - -- `AGENTS.md`: 项目级实现约束 -- `Aurask_Technical_Operations_Plan.md`: 技术与运营方案 -- `api/aurask/app.py`: 应用装配 -- `api/aurask/api.py`: HTTP 网关 -- `api/aurask/bridges/`: PostgreSQL、PGVector、Redis、AnythingLLM、Langflow 桥接配置 -- `api/aurask/auth.py`: 租户、用户与 API Key -- `api/aurask/billing.py`: 套餐、订单与权益发放 -- `api/aurask/quota.py`: TBU 预扣、结算与额度账本 -- `api/aurask/orchestrator.py`: 模板工作流编排 -- `api/aurask/knowledge_base.py`: Workspace 与文档接入 -- `api/aurask/payments.py`: USDT-TRC20 支付匹配 -- `protal/`: 用户前端使用面板 -- `manager/`: 管理员前端使用面板 -- `tests/`: 单元测试 +- `api/README.md` +- `protal/README.md` +- `deploy/k3s/README.md` +- `Aurask_Technical_Operations_Plan.md` diff --git a/api/README.md b/api/README.md index ec80c77..6debd45 100644 --- a/api/README.md +++ b/api/README.md @@ -1,50 +1,129 @@ # Aurask API -This directory contains the Python backend package and bridge configuration for: +`api/` 包含 Aurask 后端服务与外部组件桥接配置。 -- PostgreSQL -- PGVector -- Redis -- AnythingLLM -- Langflow -- Frontend-to-backend request contracts +## 当前实现范围 -## Runtime modes +- HTTP 网关:`api/aurask/api.py` +- 应用装配:`api/aurask/app.py` +- 租户、用户、API Key、Session:`api/aurask/auth.py` +- 套餐、订单、支付、TBU:`api/aurask/plans.py`、`api/aurask/billing.py`、`api/aurask/quota.py` +- 工作流编排:`api/aurask/orchestrator.py` +- AnythingLLM workspace / 文档接入:`api/aurask/knowledge_base.py` +- PostgreSQL / PGVector / Redis / AnythingLLM / Langflow 桥接:`api/aurask/bridges/` +- 门户站点配置:`api/aurask/site_config.py` -Default local mode uses `JsonStore` and simulated bridges: +## 认证模式 -```bash -uv run aurask demo --reset -uv run aurask serve --reset --host 127.0.0.1 --port 8080 +Aurask 现在支持两类 Bearer Token: + +1. API Key + - 适用于接口集成、自动化脚本 + - 由创建租户/用户或 demo bootstrap 返回 +2. Session Token + - 适用于 `protal/` 登录态 + - 由 `LY SSO` 或 Google 登录接口签发 + +## 公开接口 + +```text +GET /health +GET /plans +GET /auth/config +POST /auth/ly-sso/login +POST /auth/google/login +POST /demo/bootstrap +POST /tenants ``` -Production bridge mode can be enabled with: +## 鉴权接口 -```bash -$env:AURASK_USE_EXTERNAL_BRIDGES="true" -$env:AURASK_DATABASE_URL="postgresql://aurask:secret@postgres:5432/aurask" -$env:AURASK_REDIS_URL="redis://redis:6379/0" -$env:AURASK_ANYTHINGLLM_BASE_URL="http://anythingllm.aurask-runtime.svc.cluster.local:3001" -$env:AURASK_ANYTHINGLLM_API_KEY="" -$env:AURASK_LANGFLOW_BASE_URL="http://langflow-runtime.aurask-runtime.svc.cluster.local:7860" -$env:AURASK_LANGFLOW_API_KEY="" -uv run aurask serve --host 0.0.0.0 --port 8080 +```text +GET /auth/session +POST /auth/logout +GET /me +GET /quota +GET /workflow-templates +GET /workspaces +POST /workspaces +POST /documents +POST /orders +POST /payments/match +POST /workflow-runs +GET /workflow-runs/{run_id} +GET /admin/bridge-status ``` -## Bridge modules +## 门户与嵌入配置 -- `aurask.bridges.config`: environment-driven configuration -- `aurask.bridges.postgres`: PostgreSQL schema contract -- `aurask.bridges.pgvector`: PGVector tenant-filtered collection contract -- `aurask.bridges.redis_bridge`: Redis queue/cache/idempotency key contract -- `aurask.bridges.anythingllm`: AnythingLLM API bridge -- `aurask.bridges.langflow`: Langflow runtime bridge +`GET /auth/config` 会向前端返回: -## Admin bridge status +- 公网站点地址 +- API 地址 +- `LY SSO` / Google 开关 +- Langflow / AnythingLLM iframe 地址 +- DevCloud 对齐后的镜像与 NodePort 默认值 -Authenticated admin status endpoint: +## 环境变量 -```http -GET /admin/bridge-status -Authorization: Bearer +### 门户 / 登录 + +```text +AURASK_PUBLIC_BASE_URL=https://aurask.xyz +AURASK_PUBLIC_API_BASE_URL=https://aurask.xyz/api +AURASK_PUBLIC_LANGFLOW_URL=https://aurask.xyz/runtime/langflow/ +AURASK_PUBLIC_ANYTHINGLLM_URL=https://aurask.xyz/runtime/anythingllm/ +AURASK_LY_SSO_ENABLED=true +AURASK_LY_SSO_LOGIN_URL= +AURASK_LY_SSO_USERNAME=ly-xujian1 +AURASK_LY_SSO_PASSWORD= +AURASK_LY_SSO_EMAIL_DOMAIN=ly.szlanyou.local +AURASK_GOOGLE_ENABLED=true +AURASK_GOOGLE_CLIENT_ID= +AURASK_GOOGLE_TOKENINFO_URL=https://oauth2.googleapis.com/tokeninfo +AURASK_GOOGLE_ALLOW_UNVERIFIED_ID_TOKEN=false +AURASK_SESSION_TTL_DAYS=7 +AURASK_CORS_ALLOW_ORIGIN=https://aurask.xyz +``` + +### 运行时桥接 + +```text +AURASK_USE_EXTERNAL_BRIDGES=true +AURASK_DATABASE_URL=postgresql://aurask:@postgres.aurask-data.svc.cluster.local:5432/aurask +AURASK_POSTGRES_MIN_CONNECTIONS=1 +AURASK_POSTGRES_MAX_CONNECTIONS=10 +AURASK_PGVECTOR_TABLE=aurask_vectors +AURASK_PGVECTOR_DIMENSION=1536 +AURASK_REDIS_URL=redis://redis.aurask-data.svc.cluster.local:6379/0 +AURASK_REDIS_WORKFLOW_QUEUE=aurask:workflow-runs +AURASK_ANYTHINGLLM_BASE_URL=http://anythingllm.aurask-runtime.svc.cluster.local:3001 +AURASK_ANYTHINGLLM_API_KEY= +AURASK_LANGFLOW_BASE_URL=http://langflow-runtime.aurask-runtime.svc.cluster.local:7860 +AURASK_LANGFLOW_API_KEY= +``` + +## DevCloud 默认值 + +已内置以下默认值以对齐 `devcloud` 当前部署: + +```text +AURASK_DEVCLOUD_API_IMAGE=registry.mydevcloud.love/devcloud/aurask-api:latest +AURASK_DEVCLOUD_WEB_IMAGE=registry.mydevcloud.love/devcloud/aurask-web:latest +AURASK_DEVCLOUD_API_NODE_URL=http://45.113.2.55:30091 +AURASK_DEVCLOUD_WEB_NODE_URL=http://45.113.2.55:30090 +``` + +## 示例请求 + +接口示例文件: + +- `api/requests/aurask-api.http` + +## 本地验证 + +```powershell +$env:PYTHONPATH='api' +py -3 -m aurask serve --reset --host 127.0.0.1 --port 8080 +cmd /c "py -3 -m unittest discover -s tests -v" ``` diff --git a/api/aurask/api.py b/api/aurask/api.py index 584bb2d..9a6f5bf 100644 --- a/api/aurask/api.py +++ b/api/aurask/api.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import urlparse @@ -28,18 +29,35 @@ def make_handler(app: AuraskApp): def do_POST(self) -> None: self._handle("POST") + def do_OPTIONS(self) -> None: + self._handle("OPTIONS") + def log_message(self, format: str, *args: Any) -> None: app.audit.record("gateway.access", summary=format % args) def _handle(self, method: str) -> None: path = urlparse(self.path).path.rstrip("/") or "/" try: + if method == "OPTIONS": + self._send(204, {}) + return if method == "GET" and path == "/health": self._send(200, {"status": "ok", "service": "aurask"}) return if method == "GET" and path == "/plans": self._send(200, app.billing.list_plans()) return + if method == "GET" and path == "/auth/config": + self._send(200, app.public_auth_config()) + return + if method == "POST" and path == "/auth/ly-sso/login": + body = self._read_json() + self._send(200, app.login_with_ly_sso(username=body.get("username", ""), password=body.get("password", ""))) + return + if method == "POST" and path == "/auth/google/login": + body = self._read_json() + self._send(200, app.login_with_google(id_token=body.get("id_token", ""))) + return if method == "POST" and path == "/demo/bootstrap": body = self._read_json() self._send(201, app.bootstrap_demo(tenant_name=body.get("tenant_name", "Aurask Demo"), email=body.get("email", "owner@example.com"))) @@ -56,6 +74,12 @@ def make_handler(app: AuraskApp): tenant_id = context["tenant"]["id"] user_id = context["user"]["id"] + if method == "GET" and path in {"/auth/session", "/me"}: + self._send(200, app.session_profile(context)) + return + if method == "POST" and path == "/auth/logout": + self._send(200, app.auth.revoke_session(self.headers.get("Authorization"))) + return if method == "GET" and path == "/admin/bridge-status": self._send(200, bridge_status()) return @@ -65,6 +89,9 @@ def make_handler(app: AuraskApp): if method == "GET" and path == "/workflow-templates": self._send(200, {"templates": app.orchestrator.list_templates()}) return + if method == "GET" and path == "/workspaces": + self._send(200, {"workspaces": app.list_workspaces(tenant_id)}) + return if method == "POST" and path == "/workspaces": body = self._read_json() self._send(201, app.knowledge.create_workspace(tenant_id, user_id, body.get("name", ""))) @@ -132,13 +159,19 @@ def make_handler(app: AuraskApp): if not length: return {} payload = self.rfile.read(length).decode("utf-8") - return json.loads(payload) + decoded = json.loads(payload) + if not isinstance(decoded, dict): + return {} + return decoded - def _send(self, status: int, payload: dict) -> None: + def _send(self, status: int, payload: Any) -> None: encoded = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(encoded))) + self.send_header("Access-Control-Allow-Origin", os.getenv("AURASK_CORS_ALLOW_ORIGIN", "*")) + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept") self.end_headers() self.wfile.write(encoded) diff --git a/api/aurask/app.py b/api/aurask/app.py index aff026c..5b85fa4 100644 --- a/api/aurask/app.py +++ b/api/aurask/app.py @@ -2,9 +2,13 @@ from __future__ import annotations +import base64 +import json import os from dataclasses import dataclass from pathlib import Path +from urllib.parse import urlencode +from urllib.request import urlopen from aurask.audit import AuditService from aurask.auth import AuthService @@ -12,12 +16,14 @@ from aurask.billing import BillingService from aurask.bridges.anythingllm import AnythingLLMBridge from aurask.bridges.config import BridgeConfig from aurask.bridges.langflow import LangflowBridge +from aurask.errors import AuthError, ValidationError from aurask.knowledge_base import KnowledgeBaseService from aurask.orchestrator import WorkflowOrchestrator from aurask.payments import PaymentService from aurask.plans import seed_plan_catalog from aurask.quota import QuotaService from aurask.repository import JsonStore +from aurask.site_config import SiteConfig @dataclass @@ -30,6 +36,7 @@ class AuraskApp: payments: PaymentService knowledge: KnowledgeBaseService orchestrator: WorkflowOrchestrator + site_config: SiteConfig def bootstrap_demo(self, *, tenant_name: str = "Aurask Demo", email: str = "owner@example.com") -> dict: tenant = self.auth.create_tenant(tenant_name) @@ -44,6 +51,165 @@ class AuraskApp: "quota": self.quota.get_account(tenant["id"]), } + def public_auth_config(self) -> dict: + return self.site_config.public_dict() + + def login_with_ly_sso(self, *, username: str, password: str) -> dict: + if not self.site_config.ly_sso_enabled: + raise AuthError("LY SSO is not enabled") + if not self.site_config.ly_sso_password: + raise AuthError("LY SSO password secret is not configured") + if username != self.site_config.ly_sso_username or password != self.site_config.ly_sso_password: + raise AuthError("invalid LY SSO credentials") + email = os.getenv("AURASK_LY_SSO_EMAIL", f"{username}@{self.site_config.ly_sso_email_domain}") + return self._sign_in_external_identity( + provider="ly_sso", + subject=username, + email=email, + display_name=username, + avatar_url="", + ) + + def login_with_google(self, *, id_token: str) -> dict: + claims = self._verify_google_id_token(id_token) + email = claims.get("email", "").strip().lower() + if "@" not in email: + raise AuthError("Google account email is required") + subject = claims.get("sub") or email + return self._sign_in_external_identity( + provider="google", + subject=subject, + email=email, + display_name=claims.get("name") or email.split("@", 1)[0], + avatar_url=claims.get("picture") or "", + ) + + def session_profile(self, context: dict) -> dict: + tenant = context["tenant"] + user = context["user"] + workspace = self._ensure_default_workspace(tenant["id"], user["id"]) + payload = self._profile_payload(tenant, user, workspace) + if context.get("session"): + payload["session"] = { + "expires_at": context["session"]["expires_at"], + "provider": context["session"]["provider"], + } + return payload + + def list_workspaces(self, tenant_id: str) -> list[dict]: + return [workspace for workspace in self.store.list("workspaces") if workspace["tenant_id"] == tenant_id and workspace["status"] == "active"] + + def _sign_in_external_identity(self, *, provider: str, subject: str, email: str, display_name: str, avatar_url: str) -> dict: + identity = self.auth.get_external_identity(provider, subject) + is_new_user = False + if identity: + user = self.store.get("users", identity["user_id"]) + tenant = self.store.get("tenants", identity["tenant_id"]) + if not user or not tenant: + raise AuthError("linked identity is not available") + else: + user = self.auth.find_user_by_email(email) + if user: + tenant = self.store.get("tenants", user["tenant_id"]) + if not tenant: + raise AuthError("user tenant is not available") + else: + tenant = self.auth.create_tenant(f"{display_name}'s Workspace") + user = self.auth.create_user(tenant["id"], email, display_name=display_name, avatar_url=avatar_url) + self.billing.grant_plan_without_payment(tenant["id"], "free_trial", source=f"{provider}_signup") + is_new_user = True + self.auth.link_external_identity(provider, subject, tenant["id"], user["id"], email=email) + + self._ensure_quota_account(tenant["id"], provider) + self._refresh_user_profile(user, display_name, avatar_url) + workspace = self._ensure_default_workspace(tenant["id"], user["id"]) + session = self.auth.create_session(tenant["id"], user["id"], provider=provider, ttl_days=self.site_config.session_ttl_days) + self.audit.record( + "auth.external_login", + tenant_id=tenant["id"], + user_id=user["id"], + metadata={"provider": provider, "is_new_user": is_new_user}, + ) + payload = self._profile_payload(tenant, self.store.get("users", user["id"]) or user, workspace) + payload.update( + { + "token": session["token"], + "token_type": "Bearer", + "expires_at": session["expires_at"], + "is_new_user": is_new_user, + } + ) + return payload + + def _ensure_quota_account(self, tenant_id: str, source: str) -> None: + if not self.store.get("quota_accounts", tenant_id): + self.billing.grant_plan_without_payment(tenant_id, "free_trial", source=f"{source}_default") + + def _refresh_user_profile(self, user: dict, display_name: str, avatar_url: str) -> None: + changed = False + if display_name and user.get("display_name") != display_name: + user["display_name"] = display_name + changed = True + if avatar_url and user.get("avatar_url") != avatar_url: + user["avatar_url"] = avatar_url + changed = True + if changed: + self.store.put("users", user["id"], user) + + def _ensure_default_workspace(self, tenant_id: str, user_id: str) -> dict: + workspaces = self.list_workspaces(tenant_id) + if workspaces: + return workspaces[0] + return self.knowledge.create_workspace(tenant_id, user_id, "Personal Workspace") + + def _profile_payload(self, tenant: dict, user: dict, workspace: dict) -> dict: + return { + "user": { + "id": user["id"], + "tenant_id": user["tenant_id"], + "email": user["email"], + "display_name": user.get("display_name") or user["email"].split("@", 1)[0], + "avatar_url": user.get("avatar_url", ""), + "role": user["role"], + "status": user["status"], + "api_key_preview": user.get("api_key_preview", ""), + "created_at": user["created_at"], + }, + "tenant": tenant, + "workspace": workspace, + "quota": self.quota.get_account(tenant["id"]), + "config": self.public_auth_config(), + } + + def _verify_google_id_token(self, id_token: str) -> dict: + if not id_token: + raise ValidationError("Google id_token is required") + if self.site_config.google_allow_unverified_id_token: + return self._decode_jwt_payload(id_token) + if not self.site_config.google_enabled or not self.site_config.google_client_id: + raise AuthError("Google OAuth client is not configured") + query = urlencode({"id_token": id_token}) + try: + with urlopen(f"{self.site_config.google_tokeninfo_url}?{query}", timeout=8) as response: + claims = json.loads(response.read().decode("utf-8")) + except Exception as exc: + raise AuthError("unable to verify Google id_token") from exc + if claims.get("aud") != self.site_config.google_client_id: + raise AuthError("Google token audience mismatch") + if str(claims.get("email_verified", "")).lower() not in {"true", "1"}: + raise AuthError("Google account email is not verified") + return claims + + def _decode_jwt_payload(self, id_token: str) -> dict: + parts = id_token.split(".") + if len(parts) < 2: + raise AuthError("invalid Google id_token format") + payload = parts[1] + "=" * (-len(parts[1]) % 4) + try: + return json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8")) + except Exception as exc: + raise AuthError("invalid Google id_token payload") from exc + def create_app(data_path: str | Path | None = None, *, reset: bool = False, use_external_bridges: bool | None = None) -> AuraskApp: store = JsonStore(data_path) @@ -52,6 +218,7 @@ def create_app(data_path: str | Path | None = None, *, reset: bool = False, use_ if use_external_bridges is None: use_external_bridges = os.getenv("AURASK_USE_EXTERNAL_BRIDGES", "false").lower() in {"1", "true", "yes"} bridge_config = BridgeConfig.from_env() + site_config = SiteConfig.from_env() audit = AuditService(store) auth = AuthService(store, audit) quota = QuotaService(store, audit) @@ -72,4 +239,5 @@ def create_app(data_path: str | Path | None = None, *, reset: bool = False, use_ payments=payments, knowledge=knowledge, orchestrator=orchestrator, + site_config=site_config, ) diff --git a/api/aurask/auth.py b/api/aurask/auth.py index c7c7fa6..f47e705 100644 --- a/api/aurask/auth.py +++ b/api/aurask/auth.py @@ -3,6 +3,7 @@ from __future__ import annotations import secrets +from datetime import UTC, datetime, timedelta from aurask.audit import AuditService from aurask.errors import AuthError, ForbiddenError, NotFoundError, ValidationError @@ -29,7 +30,15 @@ class AuthService: self.audit.record("tenant.created", tenant_id=tenant["id"], resource_type="tenant", resource_id=tenant["id"]) return tenant - def create_user(self, tenant_id: str, email: str, *, role: str = "owner") -> dict: + def create_user( + self, + tenant_id: str, + email: str, + *, + role: str = "owner", + display_name: str | None = None, + avatar_url: str | None = None, + ) -> dict: self._require_tenant(tenant_id) if "@" not in email: raise ValidationError("valid email is required") @@ -38,6 +47,8 @@ class AuthService: "id": new_id("user"), "tenant_id": tenant_id, "email": email, + "display_name": display_name or email.split("@", 1)[0], + "avatar_url": avatar_url or "", "role": role, "status": "active", "api_key_preview": api_key[:10], @@ -48,20 +59,78 @@ class AuthService: self.audit.record("user.created", tenant_id=tenant_id, user_id=user["id"], resource_type="user", resource_id=user["id"]) return {**user, "api_key": api_key} + def find_user_by_email(self, email: str) -> dict | None: + normalized_email = email.strip().lower() + for user in self.store.list("users"): + if user.get("email", "").lower() == normalized_email: + return user + return None + + def get_external_identity(self, provider: str, subject: str) -> dict | None: + return self.store.get("external_identities", self._identity_key(provider, subject)) + + def link_external_identity(self, provider: str, subject: str, tenant_id: str, user_id: str, *, email: str) -> dict: + self.require_tenant_user(tenant_id, user_id) + identity = { + "id": self._identity_key(provider, subject), + "provider": provider, + "subject": subject, + "tenant_id": tenant_id, + "user_id": user_id, + "email": email, + "linked_at": now_iso(), + } + self.store.put("external_identities", identity["id"], identity) + self.audit.record( + "auth.identity_linked", + tenant_id=tenant_id, + user_id=user_id, + resource_type="external_identity", + resource_id=identity["id"], + metadata={"provider": provider}, + ) + return identity + + def create_session(self, tenant_id: str, user_id: str, *, provider: str, ttl_days: int = 7) -> dict: + self.require_tenant_user(tenant_id, user_id) + token = f"sess_{secrets.token_urlsafe(32)}" + expires_at = (datetime.now(UTC) + timedelta(days=ttl_days)).isoformat() + session = { + "token": token, + "tenant_id": tenant_id, + "user_id": user_id, + "provider": provider, + "status": "active", + "created_at": now_iso(), + "expires_at": expires_at, + } + self.store.put("sessions", token, session) + self.audit.record("auth.session_created", tenant_id=tenant_id, user_id=user_id, metadata={"provider": provider}) + return session + + def revoke_session(self, authorization_header: str | None) -> dict: + token = self._bearer_token(authorization_header) + session = self.store.get("sessions", token) + if not session: + return {"revoked": False} + session["status"] = "revoked" + session["revoked_at"] = now_iso() + self.store.put("sessions", token, session) + self.audit.record("auth.session_revoked", tenant_id=session["tenant_id"], user_id=session["user_id"]) + return {"revoked": True} + def authenticate(self, authorization_header: str | None) -> dict: - if not authorization_header or not authorization_header.startswith("Bearer "): - raise AuthError("missing bearer token") - api_key = authorization_header.removeprefix("Bearer ").strip() - key_record = self.store.get("api_keys", api_key) + bearer_token = self._bearer_token(authorization_header) + key_record = self.store.get("api_keys", bearer_token) if not key_record: - raise AuthError("invalid bearer token") + return self._authenticate_session(bearer_token) user = self.store.get("users", key_record["user_id"]) if not user or user.get("status") != "active": raise AuthError("inactive user") tenant = self.store.get("tenants", user["tenant_id"]) if not tenant or tenant.get("status") != "active": raise AuthError("inactive tenant") - return {"tenant": tenant, "user": user, "api_key": api_key} + return {"tenant": tenant, "user": user, "api_key": bearer_token, "token_type": "api_key"} def require_tenant_user(self, tenant_id: str, user_id: str) -> None: user = self.store.get("users", user_id) @@ -75,3 +144,35 @@ class AuthService: if not tenant: raise NotFoundError("tenant not found") return tenant + + def _authenticate_session(self, token: str) -> dict: + session = self.store.get("sessions", token) + if not session: + raise AuthError("invalid bearer token") + if session.get("status") != "active": + raise AuthError("inactive session") + expires_at = datetime.fromisoformat(session["expires_at"]) + if expires_at < datetime.now(UTC): + session["status"] = "expired" + self.store.put("sessions", token, session) + raise AuthError("expired session") + user = self.store.get("users", session["user_id"]) + if not user or user.get("status") != "active": + raise AuthError("inactive user") + tenant = self.store.get("tenants", session["tenant_id"]) + if not tenant or tenant.get("status") != "active": + raise AuthError("inactive tenant") + return {"tenant": tenant, "user": user, "session": session, "token_type": "session"} + + def _bearer_token(self, authorization_header: str | None) -> str: + if not authorization_header or not authorization_header.startswith("Bearer "): + raise AuthError("missing bearer token") + token = authorization_header.removeprefix("Bearer ").strip() + if not token: + raise AuthError("missing bearer token") + return token + + def _identity_key(self, provider: str, subject: str) -> str: + if not provider or not subject: + raise ValidationError("external identity provider and subject are required") + return f"{provider}:{subject}" diff --git a/api/aurask/repository.py b/api/aurask/repository.py index ef8f816..e18d7a6 100644 --- a/api/aurask/repository.py +++ b/api/aurask/repository.py @@ -20,6 +20,8 @@ DEFAULT_DATA: dict[str, Any] = { "tenants": {}, "users": {}, "api_keys": {}, + "external_identities": {}, + "sessions": {}, "plans": {}, "subscriptions": {}, "quota_accounts": {}, diff --git a/api/aurask/site_config.py b/api/aurask/site_config.py new file mode 100644 index 0000000..5b1f150 --- /dev/null +++ b/api/aurask/site_config.py @@ -0,0 +1,84 @@ +"""Public site, authentication, and embed configuration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +def _enabled(name: str, default: bool = False) -> bool: + fallback = "true" if default else "false" + return os.getenv(name, fallback).lower() in {"1", "true", "yes", "on"} + + +@dataclass(frozen=True) +class SiteConfig: + public_base_url: str + public_api_base_url: str + langflow_embed_url: str + anythingllm_embed_url: str + ly_sso_enabled: bool + ly_sso_login_url: str + ly_sso_username: str + ly_sso_password: str + ly_sso_email_domain: str + google_enabled: bool + google_client_id: str + google_tokeninfo_url: str + google_allow_unverified_id_token: bool + session_ttl_days: int + devcloud_api_image: str + devcloud_web_image: str + devcloud_api_node_url: str + devcloud_web_node_url: str + + @classmethod + def from_env(cls) -> "SiteConfig": + public_base_url = os.getenv("AURASK_PUBLIC_BASE_URL", "https://aurask.xyz").rstrip("/") + return cls( + public_base_url=public_base_url, + public_api_base_url=os.getenv("AURASK_PUBLIC_API_BASE_URL", f"{public_base_url}/api").rstrip("/"), + langflow_embed_url=os.getenv("AURASK_PUBLIC_LANGFLOW_URL", f"{public_base_url}/runtime/langflow/"), + anythingllm_embed_url=os.getenv("AURASK_PUBLIC_ANYTHINGLLM_URL", f"{public_base_url}/runtime/anythingllm/"), + ly_sso_enabled=_enabled("AURASK_LY_SSO_ENABLED", True), + ly_sso_login_url=os.getenv("AURASK_LY_SSO_LOGIN_URL", ""), + ly_sso_username=os.getenv("AURASK_LY_SSO_USERNAME", "ly-xujian1"), + ly_sso_password=os.getenv("AURASK_LY_SSO_PASSWORD", ""), + ly_sso_email_domain=os.getenv("AURASK_LY_SSO_EMAIL_DOMAIN", "ly.szlanyou.local"), + google_enabled=_enabled("AURASK_GOOGLE_ENABLED", bool(os.getenv("AURASK_GOOGLE_CLIENT_ID", ""))), + google_client_id=os.getenv("AURASK_GOOGLE_CLIENT_ID", ""), + google_tokeninfo_url=os.getenv("AURASK_GOOGLE_TOKENINFO_URL", "https://oauth2.googleapis.com/tokeninfo"), + google_allow_unverified_id_token=_enabled("AURASK_GOOGLE_ALLOW_UNVERIFIED_ID_TOKEN", False), + session_ttl_days=int(os.getenv("AURASK_SESSION_TTL_DAYS", "7")), + devcloud_api_image=os.getenv("AURASK_DEVCLOUD_API_IMAGE", "registry.mydevcloud.love/devcloud/aurask-api:latest"), + devcloud_web_image=os.getenv("AURASK_DEVCLOUD_WEB_IMAGE", "registry.mydevcloud.love/devcloud/aurask-web:latest"), + devcloud_api_node_url=os.getenv("AURASK_DEVCLOUD_API_NODE_URL", "http://45.113.2.55:30091"), + devcloud_web_node_url=os.getenv("AURASK_DEVCLOUD_WEB_NODE_URL", "http://45.113.2.55:30090"), + ) + + def public_dict(self) -> dict: + return { + "public_base_url": self.public_base_url, + "public_api_base_url": self.public_api_base_url, + "auth": { + "ly_sso": { + "enabled": self.ly_sso_enabled, + "login_url": self.ly_sso_login_url, + "username_hint": self.ly_sso_username, + }, + "google": { + "enabled": self.google_enabled, + "client_id": self.google_client_id, + }, + }, + "embeds": { + "langflow_url": self.langflow_embed_url, + "anythingllm_url": self.anythingllm_embed_url, + }, + "devcloud": { + "api_image": self.devcloud_api_image, + "web_image": self.devcloud_web_image, + "api_node_url": self.devcloud_api_node_url, + "web_node_url": self.devcloud_web_node_url, + }, + } diff --git a/api/requests/aurask-api.http b/api/requests/aurask-api.http index 3d60178..0e4ca19 100644 --- a/api/requests/aurask-api.http +++ b/api/requests/aurask-api.http @@ -4,6 +4,26 @@ GET http://127.0.0.1:8080/health ### Plans GET http://127.0.0.1:8080/plans +### Portal auth config +GET http://127.0.0.1:8080/auth/config + +### LY SSO login +POST http://127.0.0.1:8080/auth/ly-sso/login +Content-Type: application/json + +{ + "username": "ly-xujian1", + "password": "replace-with-env-password" +} + +### Google login +POST http://127.0.0.1:8080/auth/google/login +Content-Type: application/json + +{ + "id_token": "replace-with-google-id-token" +} + ### Demo bootstrap POST http://127.0.0.1:8080/demo/bootstrap Content-Type: application/json @@ -16,6 +36,17 @@ Content-Type: application/json ### Set API key after bootstrap @api_key = ak_replace_me +### Set session token after portal login +@session_token = sess_replace_me + +### Session profile +GET http://127.0.0.1:8080/auth/session +Authorization: Bearer {{session_token}} + +### Logout +POST http://127.0.0.1:8080/auth/logout +Authorization: Bearer {{session_token}} + ### Quota GET http://127.0.0.1:8080/quota Authorization: Bearer {{api_key}} @@ -24,6 +55,10 @@ Authorization: Bearer {{api_key}} GET http://127.0.0.1:8080/workflow-templates Authorization: Bearer {{api_key}} +### List workspaces +GET http://127.0.0.1:8080/workspaces +Authorization: Bearer {{api_key}} + ### Create workspace POST http://127.0.0.1:8080/workspaces Authorization: Bearer {{api_key}} diff --git a/deploy/k3s/README.md b/deploy/k3s/README.md index a331810..4c7067b 100644 --- a/deploy/k3s/README.md +++ b/deploy/k3s/README.md @@ -1,748 +1,217 @@ -# Aurask `k3s` 部署方案(当前 DevCloud 部署与后续扩展) +# Aurask k3s / DevCloud 部署说明 -## 当前 DevCloud 落地方案 +本文档基于当前仓库与 DevCloud 现网结构,兼顾两部分目标: -当前 `master` 分支已经对接 DevCloud 的实际部署形态。仓库采用 **base + production overlay** 的混合结构:`deploy/k3s/base` 保持可复用默认清单,`deploy/k3s/overlays/production` 绑定当前 3 节点 DevCloud 集群、NodePort 和节点调度策略。 +- 保留已落地的 DevCloud `base + production overlay` 部署方式 +- 纳入本次新增的门户登录流、`LY SSO` / Google、Langflow / AnythingLLM 嵌入配置 -### 当前已落地组件 - -- `aurask-api` - - 镜像:`registry.mydevcloud.love/devcloud/aurask-api` - - 节点:`45.113.2.55` - - Kubernetes 节点名:`devcloud-trade-agent-1` - - Service:`NodePort 30091` -- `aurask-web` - - 镜像:`registry.mydevcloud.love/devcloud/aurask-web` - - 来源:`protal/` - - 节点:`154.193.250.23` - - Kubernetes 节点名:`devcloud-trade-agent-2` - - Service:`NodePort 30090` -- `aurask-worker` - - 镜像:复用 `registry.mydevcloud.love/devcloud/aurask-api` - - 节点:`45.113.2.55` - - Kubernetes 节点名:`devcloud-trade-agent-1` - - 启动命令:`python -m aurask worker --data /data/state.json` -- `aurask-manager` - - 镜像:`registry.mydevcloud.love/devcloud/aurask-manager` - - 来源:`manager/` - - 节点:`154.193.250.23` - - Kubernetes 节点名:`devcloud-trade-agent-2` - - Service:`NodePort 30092` - - 当前不接入公网 Caddy,仅作为管理员入口预留 -- `postgres` - - 镜像:`pgvector/pgvector:pg16` - - 节点:`45.113.2.55` - - Kubernetes 节点名:`devcloud-trade-agent-1` - - Service:仅集群内访问 - - 用途:预置 PostgreSQL + PGVector 数据面,当前业务主存储仍为 MVP JSON -- `redis` - - 镜像:`redis:7-alpine` - - 节点:`45.113.2.55` - - Kubernetes 节点名:`devcloud-trade-agent-1` - - Service:仅集群内访问 - - 用途:预置队列、幂等、缓存、限流数据面 -- `aurask` 命名空间 -- `aurask-api-state` PVC - - `StorageClass`: `local-path` - - 用于保存 MVP JSON 状态文件 - -### 当前公网入口 - -- 域名:`https://aurask.xyz` -- 路由方式: - - `/` -> `aurask-web` - - `/api/*` -> `aurask-api` -- 边界入口通过前端节点宿主机 `Caddy` 转发: - - `154.193.250.23:443` -> `NodePort 30090 / 30091` - -### 当前仍未纳入自动部署的组件 - -以下内容仍属于后续扩展计划,**当前 `master` 分支自动发布不会部署**: - -- Langflow Runtime -- AnythingLLM -- Observability / Longhorn / CNPG - -当前 production overlay 中 `AURASK_USE_EXTERNAL_BRIDGES=false`。也就是说,PostgreSQL / PGVector / Redis 已作为生产数据面清单落地,但 Aurask 业务代码仍运行在 MVP JSON 主存储模式;待真实 PostgreSQL repository、Redis 队列消费者、Langflow 与 AnythingLLM 工作负载完成后,再切换到外部桥接模式。 - -### 自动发布流水线 - -仓库内置 Gitea Actions 工作流: - -- 文件:`.gitea/workflows/aurask-release.yml` -- 触发条件:`master` 分支 push -- 动作: - - 运行单元测试 - - 构建 `aurask-api` 镜像 - - 构建 `aurask-web` 镜像 - - 构建 `aurask-manager` 镜像 - - 推送镜像到私有仓库 - - 通过 SSH 连接 `64.90.15.15` - - `kubectl apply -k deploy/k3s/overlays/production` - - 自动更新 `aurask-api`、`aurask-worker`、`aurask-web` 与 `aurask-manager` 镜像到当前 commit SHA - -### 仓库所需 Gitea Actions Secrets - -- `SSH_PRIVATE_KEY` -- `REGISTRY_USER` -- `REGISTRY_PASSWORD` -- `POSTGRES_DB` -- `POSTGRES_USER` -- `POSTGRES_PASSWORD` - -### 当前仓库内的部署资产位置 - -```text -deploy/ - images/ - aurask-api/ - Dockerfile - aurask-web/ - Dockerfile - aurask-manager/ - Dockerfile - k3s/ - base/ - aurask-runtime-config.yaml - namespace.yaml - aurask-api-pvc.yaml - aurask-api.yaml - aurask-worker.yaml - aurask-web.yaml - aurask-manager.yaml - postgres.yaml - redis.yaml - kustomization.yaml - overlays/ - production/ - kustomization.yaml - examples/ - aurask-runtime-secrets.example.yaml - aurask-postgres-secret.example.yaml - aurask-redis-secret.example.yaml -``` - -## 目标扩展方案(300 名月度活跃用户) - -本部署计划基于当前仓库结构更新: - -```text -api/ # 后端服务、桥接配置、前端到后端请求契约 -protal/ # 用户前端使用面板 -manager/ # 管理员前端使用面板 -deploy/ # k3s 与后续部署配置 -``` - -目标是支撑 **约 300 名月度活跃付费用户**,并满足 `Aurask_Technical_Operations_Plan.md` 中的核心边界: - -- Aurask 网关是唯一公网业务入口。 -- `Langflow`、`AnythingLLM`、`PostgreSQL`、`PGVector`、`Redis` 不直接暴露公网。 -- 基础用户只运行审核模板,不开放任意代码执行。 -- AnythingLLM Workspace 与 Aurask `tenant_id` 绑定。 -- 工作流执行前预扣 TBU,执行后按实际消耗结算。 -- 支付、额度、审计、成本链路可追踪。 - -## 1. 当前代码到集群工作负载映射 - -当前仓库虽然仍是 Python 模块化单体,但已经按生产职责拆出目录和桥接层。部署时按以下方式映射: - -| 仓库目录/模块 | k3s 工作负载 | 说明 | -| --- | --- | --- | -| `api/aurask/api.py` | `aurask-api` | HTTP 网关、鉴权、订单、额度、前端请求入口 | -| `api/aurask/cli.py` | `aurask-api` / `aurask-worker` 启动入口 | 当前用同一镜像不同命令启动 | -| `api/aurask/orchestrator.py` | `aurask-worker` | 工作流编排、TBU 预扣/结算、调用 Langflow | -| `api/aurask/payments.py` | `aurask-worker` / `aurask-cron` | 支付匹配、后续链上监听 | -| `api/aurask/bridges/postgres.py` | `aurask-api` / `aurask-worker` | PostgreSQL schema contract | -| `api/aurask/bridges/pgvector.py` | `aurask-worker` | 向量检索契约,强制租户过滤 | -| `api/aurask/bridges/redis_bridge.py` | `aurask-api` / `aurask-worker` | 队列、缓存、幂等、限流 key 规则 | -| `api/aurask/bridges/anythingllm.py` | `aurask-worker` | AnythingLLM Workspace / 文档入库桥接 | -| `api/aurask/bridges/langflow.py` | `aurask-worker` | Langflow 模板运行桥接 | -| `protal/` | `aurask-protal` | 用户前端静态站点 | -| `manager/` | `aurask-manager` | 管理员前端静态站点 | -| `deploy/k3s/` | GitOps / Kustomize / Helm | 部署配置根目录 | - -## 2. 容量假设 - -300 MAU 首版不是 300 并发。按以下容量规划: - -- 月度活跃付费用户:`300` -- 日活高峰:`40-80` -- 同时在线用户峰值:`15-30` -- 同时工作流执行峰值:`10-20` -- 文档总量:`<= 500GB` -- 向量层:首版 `PostgreSQL + PGVector` -- 模型推理:外部模型 API / LLM Proxy,不在集群内自建 GPU 推理 -- Runtime:基础用户共享 Runtime Pool,高付费用户后续独立 Namespace - -触发扩容条件: - -- 持续并发工作流 `> 20` -- 文档总量 `> 500GB` -- 高付费独立空间用户 `> 20` -- 工作流排队 P95 `> 10s` -- PostgreSQL 连接数、WAL、磁盘或 CPU 持续接近上限 - -## 3. 集群拓扑 - -推荐首版生产拓扑: - -| 角色 | 数量 | 建议配置 | 用途 | -| --- | ---: | --- | --- | -| Public LB | 1 | 云负载均衡或 `HAProxy/Keepalived` | 统一暴露 `80/443` | -| `k3s` server | 3 | `4 vCPU / 8GB RAM / 120GB SSD` | 控制面 + embedded etcd | -| General worker | 2 | `8 vCPU / 16GB RAM / 200GB SSD` | `aurask-api`、前端、Ingress、观测 | -| Runtime worker | 2 | `8 vCPU / 16GB RAM / 250GB SSD` | `aurask-worker`、Langflow、AnythingLLM | -| Data worker | 可选 1-2 | `8 vCPU / 16GB RAM / 300GB+ SSD/NVMe` | PostgreSQL、Redis、存储密集型组件 | - -如果预算有限,可先用 `3 server + 4 worker`,将 Data worker 合并到 worker 池;但 PostgreSQL 节点必须通过反亲和规则打散。 - -## 4. Namespace 规划 - -| Namespace | 组件 | -| --- | --- | -| `ingress-system` | Traefik / Nginx Ingress、External DNS | -| `cert-manager` | TLS 证书 | -| `aurask-api` | `aurask-api`、API Secret、API ServiceAccount | -| `aurask-web` | `aurask-protal`、`aurask-manager` 静态前端 | -| `aurask-runtime` | `aurask-worker`、`langflow-runtime`、`anythingllm` | -| `aurask-data` | PostgreSQL、PGVector、PgBouncer、Redis | -| `observability` | Prometheus、Grafana、Loki、Alertmanager | -| `longhorn-system` | Longhorn | - -要求: - -- 默认 `NetworkPolicy` 为 `deny-all`。 -- 只允许 `aurask-api` 接收公网 Ingress 流量。 -- `aurask-protal`、`aurask-manager` 可以暴露公网,但它们只访问 `aurask-api`。 -- `Langflow`、`AnythingLLM`、`PostgreSQL`、`Redis` 仅 `ClusterIP`。 - -## 5. 应用工作负载 - -### 5.1 `aurask-api` - -来源: - -- `api/aurask/api.py` -- `api/aurask/app.py` -- `api/aurask/auth.py` -- `api/aurask/billing.py` -- `api/aurask/quota.py` - -职责: - -- HTTP 网关。 -- 鉴权。 -- 租户上下文注入。 -- 套餐、订单、额度查询。 -- 前端请求入口。 -- 管理端桥接状态接口:`GET /admin/bridge-status`。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 副本 | `3` | -| requests | `500m CPU / 1Gi RAM` | -| limits | `1 CPU / 2Gi RAM` | -| HPA | CPU 65% 或 QPS 指标,扩到 `5` | -| PDB | `minAvailable=2` | - -### 5.2 `aurask-worker` - -来源: - -- `api/aurask/orchestrator.py` -- `api/aurask/knowledge_base.py` -- `api/aurask/payments.py` -- `api/aurask/bridges/*` - -职责: - -- 工作流编排。 -- TBU 预扣与结算。 -- 调用 Langflow Runtime。 -- 调用 AnythingLLM。 -- 处理文档入库。 -- 后续消费 Redis 队列。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 副本 | `3` | -| requests | `1 CPU / 2Gi RAM` | -| limits | `2 CPU / 4Gi RAM` | -| HPA/KEDA | 按 Redis 队列长度扩到 `6` | -| 调度 | 优先 Runtime worker | - -### 5.3 `aurask-cron` - -职责: - -- 过期订单处理。 -- 支付异常扫描。 -- 周报/月报。 -- 备份检查。 -- 成本报表归集。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 类型 | `CronJob` | -| requests | `250m CPU / 512Mi RAM` | -| limits | `500m CPU / 1Gi RAM` | - -### 5.4 `aurask-protal` - -来源:`protal/` - -职责: - -- 用户登录后的使用面板。 -- 调用 Aurask API。 -- 创建 Workspace、运行模板、查看额度。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 类型 | 静态站点 Deployment | -| 副本 | `2` | -| requests | `50m CPU / 64Mi RAM` | -| limits | `200m CPU / 256Mi RAM` | -| Ingress host | `app.aurask.example.com` | - -### 5.5 `aurask-manager` - -来源:`manager/` - -职责: - -- 管理员使用面板。 -- 查看桥接状态。 -- 后续扩展租户、订单、异常支付、成本、审计。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 类型 | 静态站点 Deployment | -| 副本 | `2` | -| requests | `50m CPU / 64Mi RAM` | -| limits | `200m CPU / 256Mi RAM` | -| Ingress host | `manager.aurask.example.com` | -| 额外保护 | IP allowlist / SSO / BasicAuth / 二次认证 | - -## 6. Runtime 组件 - -### 6.1 Langflow Runtime - -部署名:`langflow-runtime` - -用途: - -- 仅执行 Aurask 审核过的模板工作流。 -- 不向普通用户暴露 Langflow UI。 -- 被 `aurask-worker` 通过内部 Service 调用。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 副本 | `3` | -| requests | `1500m CPU / 3Gi RAM` | -| limits | `3 CPU / 6Gi RAM` | -| Service | `ClusterIP` | -| 调度 | Runtime worker | - -关键环境变量: - -```text -LANGFLOW_AUTO_LOGIN=False -LANGFLOW_FALLBACK_TO_ENV_VAR=False -LANGFLOW_DATABASE_URL=postgresql://... -LANGFLOW_SECRET_KEY= -``` - -Aurask 侧桥接: - -```text -AURASK_LANGFLOW_BASE_URL=http://langflow-runtime.aurask-runtime.svc.cluster.local:7860 -AURASK_LANGFLOW_API_KEY= -``` - -### 6.2 AnythingLLM - -部署名:`anythingllm` - -用途: - -- Workspace。 -- 文档入库。 -- RAG。 -- 聊天历史。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 副本 | `2` | -| requests | `1 CPU / 2Gi RAM` | -| limits | `2 CPU / 4Gi RAM` | -| Service | `ClusterIP` | -| 调度 | Runtime worker | - -Aurask 侧桥接: - -```text -AURASK_ANYTHINGLLM_BASE_URL=http://anythingllm.aurask-runtime.svc.cluster.local:3001 -AURASK_ANYTHINGLLM_API_KEY= -``` - -要求: - -- 管理员账号仅由 Aurask 后台持有。 -- 普通用户不进入 AnythingLLM 管理后台。 -- Workspace 必须通过 Aurask 创建并绑定 `tenant_id`。 - -## 7. 数据组件 - -### 7.1 PostgreSQL + PGVector - -推荐:`CloudNativePG + PGVector` - -用途: - -- 租户、用户、订单、额度、审计。 -- 向量检索起步方案。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 实例数 | `3` | -| requests | `2 CPU / 4Gi RAM` | -| limits | `4 CPU / 8Gi RAM` | -| PVC | 每实例 `200GB` 起 | -| 备份 | WAL 归档到 S3 | - -Aurask schema 契约来源: - -- `api/aurask/bridges/postgres.py` -- `api/aurask/bridges/pgvector.py` - -必须启用: - -```sql -CREATE EXTENSION IF NOT EXISTS vector; -``` - -### 7.2 PgBouncer - -用途: - -- 降低 API / Worker 连接风暴。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 副本 | `2` | -| requests | `500m CPU / 512Mi RAM` | -| limits | `1 CPU / 1Gi RAM` | - -### 7.3 Redis - -用途: - -- 工作流队列。 -- 幂等键。 -- 限流。 -- 短期缓存。 - -建议: - -| 项目 | 值 | -| --- | --- | -| 副本 | `2-3` | -| requests | `500m CPU / 1Gi RAM` | -| limits | `1 CPU / 2Gi RAM` | - -Aurask key 契约来源: - -- `api/aurask/bridges/redis_bridge.py` - -## 8. 镜像构建计划 - -当前仓库建议构建三个镜像: - -| 镜像 | 来源目录 | 用途 | -| --- | --- | --- | -| `aurask-api` | `api/` | 后端 API 与 worker 运行时 | -| `aurask-web` | `protal/` | 用户前端静态站点 | -| `aurask-manager` | `manager/` | 管理员前端静态站点 | - -首版可先使用同一个 Python 镜像启动 `aurask-api` 与 `aurask-worker`: - -```text -aurask-api: python -m aurask serve --host 0.0.0.0 --port 8080 -aurask-worker: python -m aurask worker # 当前提供最小常驻 worker 与 --once 自检 -``` - -当前 `aurask-worker` 提供最小常驻进程、心跳日志与 `--once` 自检;真实 Redis 队列消费者完成前,它作为生产拓扑预留工作负载。 - -## 9. 环境变量 - -`aurask-api` 与 `aurask-worker` 共享: - -```text -AURASK_USE_EXTERNAL_BRIDGES=true -AURASK_DATABASE_URL=postgresql://aurask:@pgbouncer.aurask-data.svc.cluster.local:5432/aurask -AURASK_POSTGRES_MIN_CONNECTIONS=1 -AURASK_POSTGRES_MAX_CONNECTIONS=10 -AURASK_PGVECTOR_TABLE=aurask_vectors -AURASK_PGVECTOR_DIMENSION=1536 -AURASK_REDIS_URL=redis://redis.aurask-data.svc.cluster.local:6379/0 -AURASK_REDIS_WORKFLOW_QUEUE=aurask:workflow-runs -AURASK_ANYTHINGLLM_BASE_URL=http://anythingllm.aurask-runtime.svc.cluster.local:3001 -AURASK_ANYTHINGLLM_API_KEY= -AURASK_LANGFLOW_BASE_URL=http://langflow-runtime.aurask-runtime.svc.cluster.local:7860 -AURASK_LANGFLOW_API_KEY= -``` - -Secret 管理: - -- 推荐 `External Secrets Operator`。 -- 或使用 `SOPS + age` 管理 GitOps secret。 -- 禁止将真实 API Key、数据库密码、钱包配置提交到 Git。 - -## 10. Ingress 规划 - -建议域名: - -| Host | Service | 说明 | -| --- | --- | --- | -| `api.aurask.example.com` | `aurask-api` | API 网关 | -| `app.aurask.example.com` | `aurask-protal` | 用户面板 | -| `manager.aurask.example.com` | `aurask-manager` | 管理员面板 | - -不创建公网 Ingress: - -- `langflow-runtime` -- `anythingllm` -- `postgres` -- `pgbouncer` -- `redis` - -管理员面板额外要求: - -- IP allowlist。 -- SSO / BasicAuth / 二次认证。 -- 操作审计。 - -## 11. NetworkPolicy 规划 - -默认拒绝所有东西向访问,然后按链路放通: - -| From | To | Purpose | -| --- | --- | --- | -| Ingress | `aurask-api` | API 流量 | -| Ingress | `aurask-protal` | 用户面板 | -| Ingress | `aurask-manager` | 管理面板 | -| `aurask-api` | PgBouncer | 业务读写 | -| `aurask-worker` | PgBouncer | 业务读写 | -| `aurask-api` | Redis | 限流、缓存 | -| `aurask-worker` | Redis | 队列、幂等 | -| `aurask-worker` | Langflow | 模板执行 | -| `aurask-worker` | AnythingLLM | Workspace / 文档 / RAG | -| Langflow | 外部 LLM Proxy | 模型调用 | -| AnythingLLM | 外部 LLM Proxy / Object Storage | RAG 与文档处理 | - -禁止: - -- 用户请求直接访问 Langflow / AnythingLLM。 -- 前端直接访问 PostgreSQL / Redis。 -- Runtime 访问内网管理网段、云元数据地址。 - -## 12. 存储与备份 - -### 12.1 PVC - -| 组件 | 存储 | -| --- | --- | -| PostgreSQL | `cnpg-fast`,优先云块存储 / NVMe | -| Redis | `longhorn-general` 或云块存储 | -| AnythingLLM | `longhorn-critical` 或外部对象存储缓存 | -| Observability | `longhorn-general` | - -### 12.2 对象存储 - -优先外部 S3 兼容存储,用于: - -- 用户文档对象。 -- PostgreSQL 备份。 -- Longhorn 备份。 -- 审计归档。 - -### 12.3 备份目标 - -- PostgreSQL:`RPO <= 15 分钟`,`RTO <= 2 小时`。 -- Longhorn:每日快照,每周备份。 -- 支付订单与链上交易匹配记录:至少保留 `180` 天。 -- 每月至少做一次恢复演练。 - -## 13. 可观测性 - -组件: - -- Prometheus -- Grafana -- Loki -- Alertmanager -- kube-state-metrics -- node-exporter - -必须监控: - -- `aurask-api` QPS、P95、错误率。 -- `aurask-worker` 队列深度、执行时长、失败率。 -- `tbu_reserved_total`、`tbu_consumed_total`、`tbu_released_total`。 -- 订单创建、支付匹配、异常订单数量。 -- Langflow 执行耗时与错误率。 -- AnythingLLM 文档入库耗时与失败率。 -- PostgreSQL 连接数、WAL、复制延迟、磁盘。 -- Redis 内存、队列长度、延迟。 - -## 14. 推荐部署配置目录 - -后续 manifests 建议落入: +## 目录结构 ```text deploy/k3s/ README.md base/ - namespaces.yaml - ingress.yaml - network-policies.yaml - secrets.example.yaml + namespace.yaml + aurask-runtime-config.yaml + aurask-config.yaml + aurask-api-pvc.yaml aurask-api.yaml aurask-worker.yaml aurask-web.yaml aurask-manager.yaml - langflow-runtime.yaml - anythingllm.yaml - postgres-cnpg.yaml + postgres.yaml redis.yaml - observability.yaml + kustomization.yaml + secrets.example.yaml overlays/ - staging/ - kustomization.yaml production/ kustomization.yaml + aurask-api-production.yaml + aurask-worker-production.yaml + aurask-web-production.yaml + aurask-manager-production.yaml + postgres-production.yaml + redis-production.yaml + runtime-config-production.yaml + examples/ + aurask-runtime-secrets.example.yaml + aurask-postgres-secret.example.yaml + aurask-redis-secret.example.yaml ``` -Helm 管理: +## 当前 DevCloud 现网映射 -- `cert-manager` -- `longhorn` -- `cloudnative-pg` -- `prometheus-stack` -- `loki` -- `redis` operator 或 chart +### 域名与入口 -Kustomize 管理: +- 公网域名:`https://aurask.xyz` +- `https://aurask.xyz/api/*` → `aurask-api` +- `https://aurask.xyz/*` → `aurask-web` +- 当前由前端宿主机 Caddy 转发到 DevCloud NodePort -- Aurask 自研服务。 -- Ingress。 -- NetworkPolicy。 -- 环境差异。 +### 已知镜像与端口 -## 15. 上线步骤 +- API 镜像:`registry.mydevcloud.love/devcloud/aurask-api:latest` +- Web 镜像:`registry.mydevcloud.love/devcloud/aurask-web:latest` +- Manager 镜像:`registry.mydevcloud.love/devcloud/aurask-manager:latest` +- `aurask-api` NodePort:`30091` +- `aurask-web` NodePort:`30090` +- `aurask-manager` NodePort:`30092` -### Phase 1:集群底座 +### 生产 overlay 节点绑定 -1. 创建 `3 server + 4 worker` k3s 集群。 -2. 配置公网 LB 与 DNS。 -3. 安装 `cert-manager`。 -4. 安装 `Longhorn` 或云 CSI。 -5. 安装 observability 基础栈。 +- `aurask-api`:`devcloud-trade-agent-1` +- `aurask-worker`:`devcloud-trade-agent-1` +- `postgres`:`devcloud-trade-agent-1` +- `redis`:`devcloud-trade-agent-1` +- `aurask-web`:`devcloud-trade-agent-2` +- `aurask-manager`:`devcloud-trade-agent-2` -### Phase 2:数据层 +## Base 层职责 -1. 安装 `CloudNativePG`。 -2. 初始化 PostgreSQL 与 PGVector。 -3. 部署 PgBouncer。 -4. 部署 Redis。 -5. 配置对象存储与备份桶。 +`deploy/k3s/base/` 保持通用资源,不直接写死生产节点: -### Phase 3:Aurask 应用层 +- `namespace.yaml`:Aurask namespace +- `aurask-runtime-config.yaml`:桥接/运行时基础配置 +- `aurask-config.yaml`:站点、门户、登录、嵌入默认配置 +- `aurask-api-pvc.yaml`:MVP 状态文件持久化 +- `aurask-api.yaml`:API Deployment + Service +- `aurask-worker.yaml`:Worker Deployment +- `aurask-web.yaml`:用户门户 Deployment + Service +- `aurask-manager.yaml`:管理员门户 Deployment + Service +- `postgres.yaml`:PostgreSQL / PGVector 基础资源 +- `redis.yaml`:Redis 基础资源 -1. 构建并推送 `aurask-api` 镜像。 -2. 构建并推送 `aurask-web` 镜像。 -3. 构建并推送 `aurask-manager` 镜像。 -4. 部署 `aurask-api`。 -5. 部署 `aurask-worker`。 -6. 部署 `aurask-web` 与 `aurask-manager`。 -7. 配置 Ingress、TLS、NetworkPolicy。 +## 本次新增的门户配置 -### Phase 4:Runtime 层 +### `aurask-config.yaml` -1. 部署 Langflow Runtime。 -2. 部署 AnythingLLM。 -3. 配置 `AURASK_LANGFLOW_*` 与 `AURASK_ANYTHINGLLM_*` secret。 -4. 使用 `GET /admin/bridge-status` 验证桥接配置。 -5. 跑通模板工作流与文档入库。 +新增以下站点默认值: -### Phase 5:生产化验收 +- `AURASK_PUBLIC_BASE_URL=https://aurask.xyz` +- `AURASK_PUBLIC_API_BASE_URL=https://aurask.xyz/api` +- `AURASK_PUBLIC_LANGFLOW_URL=https://aurask.xyz/runtime/langflow/` +- `AURASK_PUBLIC_ANYTHINGLLM_URL=https://aurask.xyz/runtime/anythingllm/` +- `AURASK_DEVCLOUD_API_IMAGE=registry.mydevcloud.love/devcloud/aurask-api:latest` +- `AURASK_DEVCLOUD_WEB_IMAGE=registry.mydevcloud.love/devcloud/aurask-web:latest` +- `AURASK_DEVCLOUD_API_NODE_URL=http://45.113.2.55:30091` +- `AURASK_DEVCLOUD_WEB_NODE_URL=http://45.113.2.55:30090` +- `AURASK_LY_SSO_ENABLED=true` +- `AURASK_LY_SSO_USERNAME=ly-xujian1` +- `AURASK_GOOGLE_ENABLED=true` +- `AURASK_SESSION_TTL_DAYS=7` -1. 开启 HPA / KEDA。 -2. 跑并发工作流压测。 -3. 演练支付匹配。 -4. 演练 PostgreSQL 备份恢复。 -5. 演练节点故障与 Pod 重调度。 -6. 检查 Langflow / AnythingLLM 未暴露公网。 +### `secrets.example.yaml` -## 16. 验收标准 +新增门户与外部组件需要的 Secret 占位: -| 项目 | 目标 | -| --- | --- | -| API P95 | `< 500ms`,不含外部模型调用 | -| 峰值并发工作流 | `10-20` | -| 工作流成功率 | `95%+` | -| 支付匹配成功率 | `99%+` | -| 工作流排队 P95 | `< 10s` | -| PostgreSQL RPO | `<= 15 分钟` | -| PostgreSQL RTO | `<= 2 小时` | -| 月可用性 | `99%+` | -| 外部暴露面 | 仅 API、用户面板、管理面板 | +- `AURASK_DATABASE_URL` +- `AURASK_ANYTHINGLLM_API_KEY` +- `AURASK_LANGFLOW_API_KEY` +- `AURASK_LY_SSO_PASSWORD` +- `AURASK_GOOGLE_CLIENT_ID` -## 17. 当前代码差距 +说明: -当前已经具备: +- `secrets.example.yaml` 仅作模板,不应直接提交真实密钥 +- 生产建议继续使用 `External Secrets Operator` 或 `SOPS + age` -- 根目录划分:`api`、`protal`、`manager`、`deploy`。 -- PostgreSQL / PGVector / Redis / AnythingLLM / Langflow 桥接契约。 -- `/admin/bridge-status` 桥接状态接口。 -- 静态用户面板和管理面板。 -- 请求样例:`api/requests/aurask-api.http`。 +## API / Web 部署说明 -仍需补齐: +### `aurask-api` -- `deploy/k3s/base` manifests。 -- 真实 PostgreSQL repository。 -- Redis 队列消费者。 -- `aurask-worker` 独立启动命令。 -- 真实 Langflow / AnythingLLM API 适配细节校验。 -- 前端构建流程与容器镜像。 -- Secret / NetworkPolicy / HPA / KEDA manifests。 +职责: -## 18. 官方参考 +- API Gateway +- `LY SSO` / Google 登录 +- Session 签发与校验 +- 配额、订单、支付、工作流入口 +- 返回门户配置与嵌入 URL -- k3s HA embedded etcd: -- cert-manager Installation: -- Longhorn Installation: -- CloudNativePG Documentation: -- CloudNativePG Backup: -- AnythingLLM System Requirements: -- Langflow Security: +配置说明: + +- 继续挂载 `/data/state.json`,兼容当前 MVP `JsonStore` +- 同时读取: + - `aurask-runtime-config` + - `aurask-config` + - `aurask-runtime-secrets` + - `aurask-secrets` + +### `aurask-web` + +职责: + +- 承载 `protal/` +- 对外提供 `/signin` +- 登录后提供 `Workflows` / `Knowledge Base` 双标签工作台 + +## 生产 overlay 说明 + +`deploy/k3s/overlays/production/` 负责绑定现网特定配置: + +- NodeSelector +- NodePort +- 生产 runtime 开关 + +当前生产 overlay 已保留: + +- `aurask-api-production.yaml` +- `aurask-worker-production.yaml` +- `aurask-web-production.yaml` +- `aurask-manager-production.yaml` +- `postgres-production.yaml` +- `redis-production.yaml` +- `runtime-config-production.yaml` + +## 运行时嵌入建议 + +当前门户已经引用: + +- `https://aurask.xyz/runtime/langflow/` +- `https://aurask.xyz/runtime/anythingllm/` + +建议后续分两步完成: + +1. 先由公网反代到内部运行时入口 +2. 再收敛为 `aurask-api` 网关代理鉴权,避免直接暴露运行时 + +## 300 MAU 首版建议 + +### 集群规模 + +- `3` 台 k3s server +- `2` 台 general worker +- `2` 台 runtime worker + +### 组件建议 + +- `aurask-api` +- `aurask-web` +- `aurask-worker` +- `aurask-manager` +- PostgreSQL + PGVector +- Redis +- 后续补充 Langflow / AnythingLLM 专用清单 + +## 部署方式 + +### 应用基础资源 + +```powershell +kubectl apply -k deploy/k3s/base +``` + +### 应用生产 overlay + +```powershell +kubectl apply -k deploy/k3s/overlays/production +``` + +## 后续建议 + +建议继续推进: + +1. 用 PostgreSQL Repository 替换 `JsonStore` +2. 让 `aurask-worker` 接入真实 Redis 队列消费 +3. 为 Langflow / AnythingLLM 增加独立清单 +4. 增加 Ingress / TLS / NetworkPolicy +5. 让运行时访问统一收敛到 Aurask 网关代理 diff --git a/deploy/k3s/base/aurask-api.yaml b/deploy/k3s/base/aurask-api.yaml index 1ed2e3d..090daa6 100644 --- a/deploy/k3s/base/aurask-api.yaml +++ b/deploy/k3s/base/aurask-api.yaml @@ -33,9 +33,14 @@ spec: envFrom: - configMapRef: name: aurask-runtime-config + - configMapRef: + name: aurask-config - secretRef: name: aurask-runtime-secrets optional: true + - secretRef: + name: aurask-secrets + optional: true ports: - containerPort: 8080 name: http diff --git a/deploy/k3s/base/aurask-config.yaml b/deploy/k3s/base/aurask-config.yaml new file mode 100644 index 0000000..2dd1738 --- /dev/null +++ b/deploy/k3s/base/aurask-config.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: aurask-config + namespace: aurask +data: + AURASK_PUBLIC_BASE_URL: https://aurask.xyz + AURASK_PUBLIC_API_BASE_URL: https://aurask.xyz/api + AURASK_PUBLIC_LANGFLOW_URL: https://aurask.xyz/runtime/langflow/ + AURASK_PUBLIC_ANYTHINGLLM_URL: https://aurask.xyz/runtime/anythingllm/ + AURASK_DEVCLOUD_API_IMAGE: registry.mydevcloud.love/devcloud/aurask-api:latest + AURASK_DEVCLOUD_WEB_IMAGE: registry.mydevcloud.love/devcloud/aurask-web:latest + AURASK_DEVCLOUD_API_NODE_URL: http://45.113.2.55:30091 + AURASK_DEVCLOUD_WEB_NODE_URL: http://45.113.2.55:30090 + AURASK_USE_EXTERNAL_BRIDGES: "true" + AURASK_POSTGRES_MIN_CONNECTIONS: "1" + AURASK_POSTGRES_MAX_CONNECTIONS: "10" + AURASK_PGVECTOR_TABLE: aurask_vectors + AURASK_PGVECTOR_DIMENSION: "1536" + AURASK_REDIS_URL: redis://redis.aurask-data.svc.cluster.local:6379/0 + AURASK_REDIS_WORKFLOW_QUEUE: aurask:workflow-runs + AURASK_ANYTHINGLLM_BASE_URL: http://anythingllm.aurask-runtime.svc.cluster.local:3001 + AURASK_LANGFLOW_BASE_URL: http://langflow-runtime.aurask-runtime.svc.cluster.local:7860 + AURASK_LY_SSO_ENABLED: "true" + AURASK_LY_SSO_USERNAME: ly-xujian1 + AURASK_LY_SSO_EMAIL_DOMAIN: ly.szlanyou.local + AURASK_GOOGLE_ENABLED: "true" + AURASK_SESSION_TTL_DAYS: "7" + AURASK_CORS_ALLOW_ORIGIN: https://aurask.xyz diff --git a/deploy/k3s/base/kustomization.yaml b/deploy/k3s/base/kustomization.yaml index 0fb8b3b..18bc583 100644 --- a/deploy/k3s/base/kustomization.yaml +++ b/deploy/k3s/base/kustomization.yaml @@ -1,8 +1,10 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization +namespace: aurask resources: - namespace.yaml - aurask-runtime-config.yaml + - aurask-config.yaml - aurask-api-pvc.yaml - aurask-api.yaml - aurask-worker.yaml diff --git a/deploy/k3s/base/secrets.example.yaml b/deploy/k3s/base/secrets.example.yaml new file mode 100644 index 0000000..7987830 --- /dev/null +++ b/deploy/k3s/base/secrets.example.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: aurask-secrets + namespace: aurask +type: Opaque +stringData: + AURASK_DATABASE_URL: postgresql://aurask:@postgres.aurask-data.svc.cluster.local:5432/aurask + AURASK_ANYTHINGLLM_API_KEY: + AURASK_LANGFLOW_API_KEY: + AURASK_LY_SSO_PASSWORD: + AURASK_GOOGLE_CLIENT_ID: diff --git a/protal/README.md b/protal/README.md index c340151..510826c 100644 --- a/protal/README.md +++ b/protal/README.md @@ -1,15 +1,45 @@ # Aurask Protal -`protal` is the user-facing panel directory. The name follows the requested root directory spelling. +`protal/` 是用户门户目录,目录名按既定要求保留为 `protal`。 -Current MVP: +## 当前页面能力 -- Static HTML panel -- Talks directly to Aurask API -- Supports demo bootstrap, quota lookup, workspace creation, and template workflow run +- 未登录时渲染 `/signin` 风格登录页 +- 支持 `LY SSO` 登录按钮与本地凭据提交 +- 支持 Google 登录入口 +- 新用户首次登录自动创建独立 workspace +- 登录后显示: + - `Workflows`:内嵌 Langflow + - `Knowledge Base`:内嵌 AnythingLLM +- 右上角个人中心展示用户、租户、workspace 与基础额度信息 -Open `index.html` in a browser after starting: +## 依赖接口 -```bash -uv run aurask serve --reset --host 127.0.0.1 --port 8080 +门户依赖以下 API: + +- `GET /auth/config` +- `POST /auth/ly-sso/login` +- `POST /auth/google/login` +- `GET /auth/session` +- `POST /auth/logout` + +## 部署说明 + +门户默认假设: + +- 生产环境通过 `https://aurask.xyz` +- API 通过 `https://aurask.xyz/api` +- Langflow iframe 通过 `https://aurask.xyz/runtime/langflow/` +- AnythingLLM iframe 通过 `https://aurask.xyz/runtime/anythingllm/` + +本地调试时会自动回退到: + +```text +http://127.0.0.1:8080 ``` + +## 静态资源 + +- `protal/index.html` +- `protal/main.js` +- `protal/styles.css` diff --git a/protal/index.html b/protal/index.html index 386efad..c73d586 100644 --- a/protal/index.html +++ b/protal/index.html @@ -3,47 +3,11 @@ - Aurask User Protal + Aurask -
-
-

Aurask User Protal

-

Launch safe AI employee workflows

-

Bootstrap a tenant, create a knowledge workspace, and run an approved template through the Aurask gateway.

-
- -
-

Connection

- - - -
- -
-
-

Quota

- -

-        
-
-

Workspace

- - -

-        
-
- -
-

Run Template

- - - - -

-      
-
+
diff --git a/protal/main.js b/protal/main.js index a469668..dd52b0a 100644 --- a/protal/main.js +++ b/protal/main.js @@ -1,55 +1,458 @@ -const apiBaseInput = document.querySelector("#apiBase"); -const apiKeyInput = document.querySelector("#apiKey"); +const STORAGE_KEY = "aurask.portal.session"; +const TAB_KEY = "aurask.portal.activeTab"; -function apiBase() { - return apiBaseInput.value.replace(/\/$/, ""); +const app = document.querySelector("#app"); + +const state = { + config: null, + sessionToken: window.localStorage.getItem(STORAGE_KEY) || "", + profile: null, + activeTab: window.localStorage.getItem(TAB_KEY) || "workflows", + lyFormOpen: false, + loading: true, + error: "", +}; + +function detectApiBase() { + if (window.location.protocol === "file:") return "http://127.0.0.1:8080"; + if (["127.0.0.1", "localhost"].includes(window.location.hostname)) return "http://127.0.0.1:8080"; + return `${window.location.origin}/api`; +} + +function normalizeApiBase(url) { + return (url || detectApiBase()).replace(/\/$/, ""); } async function request(path, options = {}) { const headers = new Headers(options.headers || {}); - headers.set("Content-Type", "application/json"); - const apiKey = apiKeyInput.value.trim(); - if (apiKey) headers.set("Authorization", `Bearer ${apiKey}`); - const response = await fetch(`${apiBase()}${path}`, { ...options, headers }); - const payload = await response.json(); - if (!response.ok) throw new Error(JSON.stringify(payload, null, 2)); + headers.set("Accept", "application/json"); + if (options.body !== undefined) headers.set("Content-Type", "application/json"); + if (state.sessionToken) headers.set("Authorization", `Bearer ${state.sessionToken}`); + const response = await fetch(`${normalizeApiBase(state.config?.public_api_base_url)}${path}`, { + ...options, + headers, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const message = payload?.error?.message || payload?.message || "Request failed"; + const error = new Error(message); + error.status = response.status; + error.payload = payload; + throw error; + } return payload; } -function render(target, payload) { - document.querySelector(target).textContent = JSON.stringify(payload, null, 2); +function persistSession(token) { + state.sessionToken = token || ""; + if (state.sessionToken) { + window.localStorage.setItem(STORAGE_KEY, state.sessionToken); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } } -document.querySelector("#bootstrapBtn").addEventListener("click", async () => { - const payload = await request("/demo/bootstrap", { - method: "POST", - body: JSON.stringify({ tenant_name: "Aurask Protal Demo", email: "owner@example.com" }), +function setTab(tab) { + state.activeTab = tab; + window.localStorage.setItem(TAB_KEY, tab); + render(); +} + +function initials(label) { + return (label || "AU") + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); +} + +function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function withEmbedContext(baseUrl) { + if (!baseUrl) return ""; + const url = new URL(baseUrl, window.location.origin); + if (state.profile?.tenant?.id) url.searchParams.set("tenant_id", state.profile.tenant.id); + if (state.profile?.workspace?.id) url.searchParams.set("workspace_id", state.profile.workspace.id); + url.searchParams.set("source", "aurask-protal"); + return url.toString(); +} + +function ensureSigninRoute() { + if (state.profile) return; + if (window.location.pathname !== "/signin") { + window.history.replaceState({}, "", "/signin"); + } +} + +function ensureDashboardRoute() { + if (!state.profile) return; + if (!window.location.pathname.startsWith("/app")) { + window.history.replaceState({}, "", "/app"); + } +} + +async function bootstrap() { + state.loading = true; + render(); + try { + const activeApiBase = detectApiBase(); + const localHost = window.location.protocol === "file:" || ["127.0.0.1", "localhost"].includes(window.location.hostname); + state.config = await fetch(`${activeApiBase}/auth/config`).then((response) => response.json()); + state.config.public_api_base_url = normalizeApiBase(localHost ? activeApiBase : state.config.public_api_base_url || activeApiBase); + if (state.sessionToken) { + try { + state.profile = await request("/auth/session"); + ensureDashboardRoute(); + } catch (error) { + persistSession(""); + state.profile = null; + } + } + if (!state.profile) ensureSigninRoute(); + } catch (error) { + state.error = error.message || "Failed to load Aurask configuration"; + } finally { + state.loading = false; + render(); + mountGoogleButton(); + } +} + +async function submitLySso(event) { + event.preventDefault(); + state.error = ""; + render(); + const form = new FormData(event.currentTarget); + try { + const payload = await request("/auth/ly-sso/login", { + method: "POST", + body: { + username: String(form.get("username") || "").trim(), + password: String(form.get("password") || ""), + }, + }); + persistSession(payload.token); + state.profile = payload; + state.lyFormOpen = false; + ensureDashboardRoute(); + } catch (error) { + state.error = error.message || "LY SSO sign-in failed"; + } finally { + render(); + mountGoogleButton(); + } +} + +async function signInWithGoogle(idToken) { + state.error = ""; + render(); + try { + const payload = await request("/auth/google/login", { + method: "POST", + body: { id_token: idToken }, + }); + persistSession(payload.token); + state.profile = payload; + ensureDashboardRoute(); + } catch (error) { + state.error = error.message || "Google sign-in failed"; + render(); + } +} + +async function logout() { + try { + await request("/auth/logout", { method: "POST" }); + } catch (error) { + console.warn("Logout request failed", error); + } + persistSession(""); + state.profile = null; + state.lyFormOpen = false; + ensureSigninRoute(); + render(); + mountGoogleButton(); +} + +function mountGoogleButton() { + const googleBox = document.querySelector("#googleButton"); + if (!googleBox || !state.config?.auth?.google?.enabled || !state.config?.auth?.google?.client_id) return; + if (!window.google?.accounts?.id) return; + googleBox.innerHTML = ""; + window.google.accounts.id.initialize({ + client_id: state.config.auth.google.client_id, + callback: ({ credential }) => signInWithGoogle(credential), }); - apiKeyInput.value = payload.api_key; - document.querySelector("#workspaceId").value = payload.workspace.id; - render("#quotaOutput", payload.quota); - render("#workspaceOutput", payload.workspace); -}); - -document.querySelector("#quotaBtn").addEventListener("click", async () => { - render("#quotaOutput", await request("/quota")); -}); - -document.querySelector("#workspaceBtn").addEventListener("click", async () => { - const payload = await request("/workspaces", { - method: "POST", - body: JSON.stringify({ name: document.querySelector("#workspaceName").value }), + window.google.accounts.id.renderButton(googleBox, { + theme: "outline", + size: "large", + shape: "pill", + text: "signup_with", + width: 320, }); - document.querySelector("#workspaceId").value = payload.id; - render("#workspaceOutput", payload); -}); +} -document.querySelector("#runBtn").addEventListener("click", async () => { - const workspaceId = document.querySelector("#workspaceId").value.trim(); - const body = { - template_id: document.querySelector("#templateId").value, - inputs: { prompt: document.querySelector("#prompt").value }, - }; - if (workspaceId) body.workspace_id = workspaceId; - render("#runOutput", await request("/workflow-runs", { method: "POST", body: JSON.stringify(body) })); -}); +function renderStatusPills() { + if (!state.config) return ""; + return ` +
+ API: ${escapeHtml(state.config.public_api_base_url)} + Web: ${escapeHtml(state.config.public_base_url)} + DevCloud API Image Ready +
+ `; +} + +function renderSignin() { + const lyConfig = state.config?.auth?.ly_sso || {}; + const googleConfig = state.config?.auth?.google || {}; + const googleEnabled = Boolean(googleConfig.enabled && googleConfig.client_id); + app.innerHTML = ` +
+ + +
+ `; + + document.querySelector("#lyButton")?.addEventListener("click", () => { + if (lyConfig.login_url) { + window.location.href = lyConfig.login_url; + return; + } + state.lyFormOpen = !state.lyFormOpen; + render(); + mountGoogleButton(); + }); + + document.querySelector("#lyForm")?.addEventListener("submit", submitLySso); + document.querySelector("#googleFallbackButton")?.addEventListener("click", () => { + document.querySelector("#googleButton")?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); +} + +function renderDashboard() { + const user = state.profile?.user || {}; + const tenant = state.profile?.tenant || {}; + const workspace = state.profile?.workspace || {}; + const quota = state.profile?.quota || {}; + const config = state.profile?.config || state.config || {}; + const langflowUrl = withEmbedContext(config.embeds?.langflow_url); + const anythingllmUrl = withEmbedContext(config.embeds?.anythingllm_url); + app.innerHTML = ` +
+
+
+
A
+
+

Aurask Workspace

+

${escapeHtml(tenant.name || "Aurask")}

+
+
+
+ +
+ + ${user.avatar_url ? `${escapeHtml(user.display_name || user.email)}` : `${escapeHtml(initials(user.display_name || user.email))}`} + ${escapeHtml(user.display_name || user.email || "Profile")} + +
+
+ ${escapeHtml(user.display_name || user.email || "")} + ${escapeHtml(user.email || "")} +
+
+ Tenant + ${escapeHtml(tenant.name || "")} +
+
+ Workspace + ${escapeHtml(workspace.name || "")} +
+
+ Plan + ${escapeHtml(quota.plan_code || "")} + TBU + ${escapeHtml(String(quota.available_tbu ?? 0))} + Knowledge Bases + ${escapeHtml(String(quota.knowledge_bases ?? 0))} +
+ +
+
+
+
+
+ +
+
+
+
+

Workflow Studio

+

Langflow

+
+ Open in new tab +
+ +
+
+
+
+

Knowledge Base

+

AnythingLLM

+
+ Open in new tab +
+ +
+
+
+ ${state.error ? `
${escapeHtml(state.error)}
` : ""} +
+ `; + + document.querySelectorAll("[data-tab]").forEach((button) => { + button.addEventListener("click", () => setTab(button.dataset.tab)); + }); + document.querySelector("#logoutButton")?.addEventListener("click", logout); +} + +function renderLoading() { + app.innerHTML = ` +
+
+

Loading Aurask...

+
+ `; +} + +function render() { + if (state.loading) { + renderLoading(); + return; + } + if (state.profile) { + renderDashboard(); + return; + } + renderSignin(); +} + +window.addEventListener("popstate", render); + +if (state.sessionToken) { + document.addEventListener("visibilitychange", async () => { + if (document.visibilityState !== "visible" || !state.profile) return; + try { + state.profile = await request("/auth/session"); + render(); + } catch (error) { + persistSession(""); + state.profile = null; + state.error = "Your Aurask session has expired. Please sign in again."; + ensureSigninRoute(); + render(); + mountGoogleButton(); + } + }); +} + +const googleScript = document.createElement("script"); +googleScript.src = "https://accounts.google.com/gsi/client"; +googleScript.async = true; +googleScript.defer = true; +googleScript.onload = mountGoogleButton; +document.head.appendChild(googleScript); + +bootstrap(); diff --git a/protal/styles.css b/protal/styles.css index 54f50c4..5ec89d7 100644 --- a/protal/styles.css +++ b/protal/styles.css @@ -1,83 +1,584 @@ :root { color-scheme: light; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f6f7fb; - color: #182033; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + background: #f6f8fb; + color: #102033; +} + +* { + box-sizing: border-box; } body { + min-height: 100vh; margin: 0; + background: + radial-gradient(circle at top left, rgba(76, 111, 255, 0.2), transparent 34rem), + radial-gradient(circle at bottom right, rgba(14, 165, 233, 0.16), transparent 30rem), + #f7f9fc; } -.shell { - max-width: 1120px; - margin: 0 auto; - padding: 40px 20px; -} - -.hero { - padding: 32px; - border-radius: 24px; - background: linear-gradient(135deg, #172554, #2563eb); - color: white; - margin-bottom: 24px; -} - -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.14em; - opacity: 0.8; -} - -.grid { - display: grid; - gap: 20px; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); -} - -.card { - background: white; - border: 1px solid #e5e7eb; - border-radius: 20px; - padding: 24px; - margin-bottom: 20px; - box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06); -} - -label { - display: grid; - gap: 8px; - margin: 12px 0; - font-weight: 600; -} - -input, -textarea { - border: 1px solid #cbd5e1; - border-radius: 12px; - padding: 12px; +button, +input { font: inherit; } -textarea { - min-height: 100px; -} - button { - border: 0; - border-radius: 999px; - background: #2563eb; - color: white; - padding: 12px 18px; - font-weight: 700; cursor: pointer; } -pre { - overflow: auto; - background: #0f172a; - color: #dbeafe; - border-radius: 14px; - padding: 16px; - min-height: 72px; +.app-shell, +.signin-layout, +.dashboard-shell, +.loading-shell { + min-height: 100vh; +} + +.signin-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 470px); + gap: 48px; + align-items: center; + width: min(1180px, calc(100% - 40px)); + margin: 0 auto; + padding: 54px 0; +} + +.signin-brand { + padding: 48px; + color: #f8fbff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 34px; + background: + linear-gradient(145deg, rgba(13, 33, 90, 0.96), rgba(37, 99, 235, 0.9)), + #132965; + box-shadow: 0 28px 80px rgba(37, 99, 235, 0.24); +} + +.brand-mark { + display: grid; + width: 58px; + height: 58px; + place-items: center; + border-radius: 18px; + color: #1d4ed8; + background: #ffffff; + font-size: 28px; + font-weight: 900; + box-shadow: 0 18px 46px rgba(15, 23, 42, 0.18); +} + +.brand-mark-small { + width: 44px; + height: 44px; + border-radius: 14px; + color: #ffffff; + background: linear-gradient(135deg, #2563eb, #06b6d4); + font-size: 22px; +} + +.brand-eyebrow, +.section-kicker { + margin: 0; + color: #2563eb; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.signin-brand .brand-eyebrow, +.signin-brand .section-kicker { + color: #bfdbfe; +} + +.signin-brand h1 { + max-width: 760px; + margin: 28px 0 18px; + font-size: clamp(38px, 6vw, 76px); + line-height: 0.95; + letter-spacing: -0.06em; +} + +.brand-copy { + max-width: 640px; + margin: 0; + color: rgba(248, 251, 255, 0.78); + font-size: 18px; + line-height: 1.65; +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 34px; +} + +.pill { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 6px 12px; + color: #dbeafe; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + font-size: 12px; + font-weight: 700; +} + +.signin-card { + padding: 34px; + border: 1px solid #e4e9f2; + border-radius: 30px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 30px 90px rgba(15, 23, 42, 0.12); + backdrop-filter: blur(16px); +} + +.signin-header h2 { + margin: 8px 0 8px; + color: #0f172a; + font-size: 30px; + letter-spacing: -0.03em; +} + +.signin-header p { + margin: 0; + color: #667085; + line-height: 1.6; +} + +.signin-actions, +.ly-form { + display: grid; + gap: 14px; + margin-top: 26px; +} + +.auth-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 48px; + padding: 12px 18px; + border: 0; + border-radius: 999px; + font-weight: 800; + transition: + transform 160ms ease, + box-shadow 160ms ease, + opacity 160ms ease; +} + +.auth-btn:hover:not(:disabled) { + transform: translateY(-1px); +} + +.auth-btn:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.auth-btn-primary { + color: #ffffff; + background: linear-gradient(135deg, #2563eb, #0ea5e9); + box-shadow: 0 16px 38px rgba(37, 99, 235, 0.24); +} + +.auth-btn-secondary { + color: #0f172a; + background: #eef4ff; + border: 1px solid #dbe7ff; +} + +.google-box { + display: flex; + justify-content: center; + min-height: 48px; + margin-top: 14px; +} + +.ly-form label { + display: grid; + gap: 8px; + color: #344054; + font-weight: 700; +} + +.ly-form input { + width: 100%; + min-height: 48px; + padding: 12px 14px; + color: #101828; + border: 1px solid #d0d5dd; + border-radius: 14px; + outline: none; +} + +.ly-form input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1); +} + +.signin-tip, +.signin-footer, +.message { + color: #667085; + font-size: 13px; + line-height: 1.55; +} + +.signin-footer { + display: grid; + gap: 2px; + margin-top: 28px; + padding-top: 18px; + border-top: 1px solid #edf2f7; +} + +.error { + color: #b42318; +} + +.dashboard-shell { + width: min(1440px, calc(100% - 32px)); + margin: 0 auto; + padding: 18px 0 28px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 76px; + padding: 14px 18px; + border: 1px solid rgba(226, 232, 240, 0.92); + border-radius: 24px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); + backdrop-filter: blur(14px); +} + +.topbar-left, +.topbar-right { + display: flex; + align-items: center; + gap: 14px; +} + +.topbar h1 { + margin: 2px 0 0; + font-size: 20px; + letter-spacing: -0.02em; +} + +.tabbar { + display: inline-flex; + gap: 6px; + padding: 5px; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #f8fafc; +} + +.tab { + min-height: 38px; + padding: 0 16px; + color: #475569; + border: 0; + border-radius: 999px; + background: transparent; + font-weight: 800; +} + +.tab.is-active { + color: #ffffff; + background: #2563eb; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.18); +} + +.profile-menu { + position: relative; +} + +.profile-menu summary { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 4px 12px 4px 4px; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #ffffff; + list-style: none; + cursor: pointer; +} + +.profile-menu summary::-webkit-details-marker { + display: none; +} + +.avatar, +.avatar-image { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 999px; + color: #ffffff; + background: linear-gradient(135deg, #0f172a, #2563eb); + font-weight: 900; +} + +.avatar-image { + object-fit: cover; +} + +.profile-name { + max-width: 140px; + overflow: hidden; + color: #0f172a; + font-size: 14px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + display: grid; + gap: 14px; + width: 310px; + padding: 18px; + border: 1px solid #e2e8f0; + border-radius: 22px; + background: #ffffff; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.16); +} + +.profile-block { + display: grid; + gap: 4px; + color: #667085; + font-size: 13px; +} + +.profile-block strong { + overflow-wrap: anywhere; + color: #0f172a; +} + +.profile-grid { + grid-template-columns: 1fr auto; +} + +.dashboard-grid { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + gap: 18px; + margin-top: 18px; +} + +.side-panel { + display: grid; + align-content: start; + gap: 18px; +} + +.info-card, +.embed-card { + border: 1px solid #e2e8f0; + border-radius: 24px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 18px 50px rgba(15, 23, 42, 0.07); +} + +.info-card { + padding: 22px; +} + +.info-card h2 { + margin: 8px 0 4px; + font-size: 22px; + letter-spacing: -0.03em; +} + +.info-card p { + margin: 0; + color: #667085; +} + +.stat-list { + display: grid; + gap: 12px; + margin-top: 20px; +} + +.stat-item { + display: grid; + gap: 4px; + padding: 12px; + border-radius: 16px; + background: #f8fafc; +} + +.stat-item span, +.plain-list { + color: #667085; + font-size: 13px; +} + +.stat-item strong { + overflow-wrap: anywhere; + color: #0f172a; + font-size: 13px; +} + +.plain-list { + display: grid; + gap: 10px; + padding-left: 18px; +} + +.workspace-panel { + min-width: 0; +} + +.embed-card { + overflow: hidden; +} + +.embed-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 76px; + padding: 18px 22px; + border-bottom: 1px solid #e2e8f0; +} + +.embed-header h2 { + margin: 4px 0 0; + font-size: 24px; +} + +.embed-header a { + color: #2563eb; + font-size: 14px; + font-weight: 800; + text-decoration: none; +} + +iframe { + display: block; + width: 100%; + height: calc(100vh - 150px); + min-height: 680px; + border: 0; + background: #f8fafc; +} + +.loading-shell { + display: grid; + place-items: center; + align-content: center; + gap: 16px; + color: #475569; +} + +.loader { + width: 44px; + height: 44px; + border: 4px solid #dbeafe; + border-top-color: #2563eb; + border-radius: 999px; + animation: spin 900ms linear infinite; +} + +.toast { + position: fixed; + right: 24px; + bottom: 24px; + max-width: 420px; + padding: 14px 18px; + border: 1px solid #fecaca; + border-radius: 18px; + background: #fff1f2; + box-shadow: 0 18px 50px rgba(15, 23, 42, 0.1); +} + +.is-hidden { + display: none !important; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1020px) { + .signin-layout, + .dashboard-grid { + grid-template-columns: 1fr; + } + + .signin-brand { + padding: 34px; + } + + .topbar { + align-items: stretch; + flex-direction: column; + } + + .topbar-right { + justify-content: space-between; + } +} + +@media (max-width: 680px) { + .signin-layout, + .dashboard-shell { + width: min(100% - 20px, 100%); + } + + .signin-card, + .signin-brand, + .info-card { + border-radius: 22px; + padding: 22px; + } + + .topbar-right { + align-items: stretch; + flex-direction: column-reverse; + } + + .tabbar, + .profile-menu summary, + .profile-panel { + width: 100%; + } + + .profile-panel { + position: static; + margin-top: 10px; + } + + iframe { + min-height: 560px; + } } diff --git a/tests/test_auth_sessions.py b/tests/test_auth_sessions.py new file mode 100644 index 0000000..1a0f3cc --- /dev/null +++ b/tests/test_auth_sessions.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import base64 +import json +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api")) + +from aurask.app import create_app + + +class AuthSessionTests(unittest.TestCase): + def setUp(self) -> None: + self.env_keys = [ + "AURASK_LY_SSO_PASSWORD", + "AURASK_GOOGLE_ENABLED", + "AURASK_GOOGLE_ALLOW_UNVERIFIED_ID_TOKEN", + "AURASK_GOOGLE_CLIENT_ID", + ] + self.old_env = {key: os.environ.get(key) for key in self.env_keys} + + def tearDown(self) -> None: + for key, value in self.old_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def test_ly_sso_login_creates_session_and_workspace(self) -> None: + os.environ["AURASK_LY_SSO_PASSWORD"] = "local-secret" + app = create_app(None, reset=True) + + payload = app.login_with_ly_sso(username="ly-xujian1", password="local-secret") + context = app.auth.authenticate(f"Bearer {payload['token']}") + + self.assertEqual(context["token_type"], "session") + self.assertEqual(payload["workspace"]["tenant_id"], payload["tenant"]["id"]) + self.assertEqual(payload["quota"]["plan_code"], "free_trial") + self.assertEqual(len(app.list_workspaces(payload["tenant"]["id"])), 1) + + def test_google_first_login_reuses_workspace_after_registration(self) -> None: + os.environ["AURASK_GOOGLE_ENABLED"] = "true" + os.environ["AURASK_GOOGLE_ALLOW_UNVERIFIED_ID_TOKEN"] = "true" + os.environ["AURASK_GOOGLE_CLIENT_ID"] = "local-google-client" + app = create_app(None, reset=True) + token = self._fake_google_token( + { + "sub": "google-user-1", + "email": "founder@example.com", + "email_verified": True, + "name": "Aurask Founder", + } + ) + + first_login = app.login_with_google(id_token=token) + second_login = app.login_with_google(id_token=token) + + self.assertTrue(first_login["is_new_user"]) + self.assertFalse(second_login["is_new_user"]) + self.assertEqual(first_login["workspace"]["id"], second_login["workspace"]["id"]) + self.assertEqual(len(app.list_workspaces(first_login["tenant"]["id"])), 1) + + def _fake_google_token(self, claims: dict) -> str: + header = self._base64url({"alg": "none", "typ": "JWT"}) + payload = self._base64url(claims) + return f"{header}.{payload}.signature" + + def _base64url(self, payload: dict) -> str: + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8") + return encoded.rstrip("=") + + +if __name__ == "__main__": + unittest.main()