Compare commits
11 Commits
cec6dce955
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47ed59c9d6 | |||
| 931d82cbb9 | |||
| 5f4e9bdfa7 | |||
| b797e360c0 | |||
| 1ccfd3d626 | |||
| dbbfb747a4 | |||
| a0bea36041 | |||
| 63ca1151ae | |||
| 1000f8a80d | |||
| 9eb77ab98b | |||
| 420dedbc1e |
@@ -0,0 +1,8 @@
|
|||||||
|
.codegraph
|
||||||
|
.claude
|
||||||
|
target
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
.env
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
# HTTP Server
|
||||||
|
APP_HTTP_HOST=0.0.0.0
|
||||||
|
APP_HTTP_PORT=8000
|
||||||
|
APP_HTTP_WORKERS=4
|
||||||
|
APP_HTTP_JSON_LIMIT_BYTES=10485760
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
APP_MAIN_DOMAIN=localhost
|
||||||
|
|
||||||
|
# Session
|
||||||
|
APP_SESSION_SECRET=change-me-to-a-secure-random-string-at-least-32-bytes
|
||||||
|
APP_SESSION_COOKIE_NAME=sid
|
||||||
|
APP_SESSION_COOKIE_SECURE=false
|
||||||
|
APP_SESSION_COOKIE_HTTP_ONLY=true
|
||||||
|
APP_SESSION_COOKIE_SAME_SITE=Lax
|
||||||
|
APP_SESSION_COOKIE_PATH=/
|
||||||
|
APP_SESSION_COOKIE_DOMAIN=
|
||||||
|
APP_SESSION_TTL_SECS=86400
|
||||||
|
APP_SESSION_MAX_AGE_SECS=86400
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
DATABASE_URL=postgres://appks:appks@localhost:5432/appks
|
||||||
|
APP_DATABASE_URL=postgres://appks:appks@localhost:5432/appks
|
||||||
|
APP_DATABASE_MAX_CONNECTIONS=10
|
||||||
|
APP_DATABASE_MIN_CONNECTIONS=2
|
||||||
|
APP_DATABASE_IDLE_TIMEOUT=600
|
||||||
|
APP_DATABASE_MAX_LIFETIME=3600
|
||||||
|
APP_DATABASE_CONNECTION_TIMEOUT=8
|
||||||
|
APP_DATABASE_SCHEMA_SEARCH_PATH=public
|
||||||
|
APP_DATABASE_READ_WRITE_SPLIT=false
|
||||||
|
APP_DATABASE_RETRY_ATTEMPTS=3
|
||||||
|
APP_DATABASE_RETRY_DELAY=5
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
# Single-node mode (set APP_REDIS_CLUSTER_ENABLED=false)
|
||||||
|
APP_REDIS_URL=redis://localhost:6379/0
|
||||||
|
# Cluster mode (set APP_REDIS_CLUSTER_ENABLED=true)
|
||||||
|
APP_REDIS_CLUSTER_ENABLED=true
|
||||||
|
APP_REDIS_CLUSTER_NODES=redis://localhost:6379,redis://localhost:6380,redis://localhost:6381,redis://localhost:6382,redis://localhost:6383,redis://localhost:6384
|
||||||
|
APP_REDIS_READ_FROM_REPLICAS=false
|
||||||
|
APP_REDIS_USERNAME=
|
||||||
|
APP_REDIS_PASSWORD=
|
||||||
|
APP_REDIS_MAX_CONNECTIONS=20
|
||||||
|
APP_REDIS_MIN_CONNECTIONS=2
|
||||||
|
APP_REDIS_IDLE_TIMEOUT=300
|
||||||
|
APP_REDIS_CONNECTION_TIMEOUT=5
|
||||||
|
APP_REDIS_MAX_RETRIES=3
|
||||||
|
APP_REDIS_RETRY_DELAY_MS=100
|
||||||
|
APP_REDIS_TLS_ENABLED=false
|
||||||
|
APP_REDIS_KEY_PREFIX=appks:
|
||||||
|
|
||||||
|
# etcd
|
||||||
|
APP_ETCD_ENDPOINTS=http://localhost:2379
|
||||||
|
APP_ETCD_KEY_PREFIX=/appks/
|
||||||
|
APP_ETCD_CONNECT_TIMEOUT=5
|
||||||
|
APP_ETCD_REQUEST_TIMEOUT=10
|
||||||
|
APP_ETCD_KEEP_ALIVE_INTERVAL=10
|
||||||
|
APP_ETCD_LEASE_TTL=15
|
||||||
|
APP_ETCD_MAX_RETRIES=3
|
||||||
|
APP_ETCD_REGISTER_SELF=false
|
||||||
|
|
||||||
|
# NATS
|
||||||
|
APP_NATS_URL=nats://localhost:4222
|
||||||
|
APP_NATS_CONNECTION_TIMEOUT=5
|
||||||
|
APP_NATS_PING_INTERVAL=20
|
||||||
|
APP_NATS_RECONNECT_DELAY=2
|
||||||
|
APP_NATS_MAX_RECONNECTS=60
|
||||||
|
APP_NATS_STREAM_PREFIX=APPKS
|
||||||
|
APP_NATS_ACK_WAIT_SECS=30
|
||||||
|
APP_NATS_MAX_DELIVER=5
|
||||||
|
|
||||||
|
# S3 / MinIO
|
||||||
|
APP_S3_ENDPOINT=http://localhost:9000
|
||||||
|
APP_S3_REGION=us-east-1
|
||||||
|
APP_S3_ACCESS_KEY=admin
|
||||||
|
APP_S3_SECRET_KEY=mysecret123
|
||||||
|
APP_S3_BUCKET=appks
|
||||||
|
APP_S3_PATH_STYLE=true
|
||||||
|
APP_S3_FORCE_PATH_STYLE=true
|
||||||
|
APP_S3_PUBLIC_URL=http://localhost:9000/appks
|
||||||
|
APP_S3_MAX_CONNECTIONS=50
|
||||||
|
APP_S3_IDLE_TIMEOUT=90
|
||||||
|
APP_S3_CONNECTION_TIMEOUT=10
|
||||||
|
APP_S3_MAX_RETRIES=3
|
||||||
|
APP_S3_UPLOAD_PART_SIZE=8388608
|
||||||
|
APP_S3_MAX_UPLOAD_SIZE=104857600
|
||||||
|
APP_S3_PRESIGNED_URL_EXPIRY=3600
|
||||||
|
|
||||||
|
# LRU Cache
|
||||||
|
APP_LRU_DEFAULT_CAPACITY=1000
|
||||||
|
APP_LRU_DEFAULT_TTL_SECS=300
|
||||||
|
APP_LRU_CLEANUP_INTERVAL_SECS=60
|
||||||
|
|
||||||
|
# gRPC Server
|
||||||
|
APP_RPC_SELF_HOST=0.0.0.0
|
||||||
|
APP_RPC_SELF_PORT=50049
|
||||||
|
APP_RPC_SELF_REFLECTION=false
|
||||||
|
APP_RPC_SELF_SERVICE_NAME=appks
|
||||||
|
APP_RPC_DEFAULT_TIMEOUT_SECS=10
|
||||||
|
|
||||||
|
# AI Provider
|
||||||
|
APP_AI_PROVIDER_API_KEY=
|
||||||
|
APP_AI_PROVIDER_URL=
|
||||||
|
|
||||||
|
# Qdrant
|
||||||
|
APP_QDRANT_URL=http://localhost:6334
|
||||||
|
APP_QDRANT_COLLECTION=appks_embeddings
|
||||||
|
APP_QDRANT_VECTOR_SIZE=1536
|
||||||
|
APP_QDRANT_DISTANCE=Cosine
|
||||||
|
APP_QDRANT_MAX_CONNECTIONS=10
|
||||||
|
APP_QDRANT_IDLE_TIMEOUT=300
|
||||||
|
APP_QDRANT_CONNECTION_TIMEOUT=10
|
||||||
|
APP_QDRANT_MAX_RETRIES=3
|
||||||
|
APP_QDRANT_TLS_ENABLED=false
|
||||||
|
APP_QDRANT_SEARCH_LIMIT=10
|
||||||
|
APP_QDRANT_SCORE_THRESHOLD=0.7
|
||||||
|
|
||||||
|
# Email RPC
|
||||||
|
APP_EMAIL_RPC_ADDR=http://localhost:50050
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
# AGENTS.md — 开发规范 / Development Guidelines
|
||||||
|
|
||||||
|
> 本文件为所有 AI 编码助手(Claude Code、pi、Cursor 等)提供统一的开发指导。
|
||||||
|
> This file provides unified development guidelines for all AI coding assistants.
|
||||||
|
|
||||||
|
**最后更新 / Last Updated**: 2026-06-10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录 / Table of Contents
|
||||||
|
|
||||||
|
1. [语言 / Language](#1-语言--language)
|
||||||
|
2. [代码风格 / Code Style](#2-代码风格--code-style)
|
||||||
|
3. [禁止模式 / Forbidden Patterns](#3-禁止模式--forbidden-patterns)
|
||||||
|
4. [错误处理 / Error Handling](#4-错误处理--error-handling)
|
||||||
|
5. [安全规范 / Security](#5-安全规范--security)
|
||||||
|
6. [数据库规范 / Database](#6-数据库规范--database)
|
||||||
|
7. [API 设计规范 / API Design](#7-api-设计规范--api-design)
|
||||||
|
8. [日志与可观测性 / Logging & Observability](#8-日志与可观测性--logging--observability)
|
||||||
|
9. [性能规范 / Performance](#9-性能规范--performance)
|
||||||
|
10. [测试规范 / Testing](#10-测试规范--testing)
|
||||||
|
11. [Git 规范 / Git Workflow](#11-git-规范--git-workflow)
|
||||||
|
12. [工作流程 / Workflow](#12-工作流程--workflow)
|
||||||
|
13. [架构决策记录 / ADR](#13-架构决策记录--adr)
|
||||||
|
14. [审查清单 / Review Checklist](#14-审查清单--review-checklist)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 语言 / Language
|
||||||
|
|
||||||
|
**Always respond in Chinese (中文).** Use the user's language for all conversations and explanations. Code, commands, and technical terms can remain in English.
|
||||||
|
|
||||||
|
始终使用中文回复。代码、命令和技术术语可以保留英文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 代码风格 / Code Style
|
||||||
|
|
||||||
|
### 2.1 基本原则 / Basic Principles
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|-----------|-----------------------------------------------------------------------------------------|
|
||||||
|
| 遵循现有风格 | Follow existing project conventions |
|
||||||
|
| 有意义命名 | Use meaningful variable names; avoid single-letter names except loop counters |
|
||||||
|
| 函数长度 | Keep functions under **50 lines**; split complex logic into smaller functions |
|
||||||
|
| 嵌套深度 | Maximum nesting depth: **3 levels**; use early returns to flatten logic |
|
||||||
|
| 圈复杂度 | Function cyclomatic complexity should not exceed **10** |
|
||||||
|
| 注释 | Add comments for complex logic only; prefer self-documenting code |
|
||||||
|
| 文档注释 | Public items must have `///` doc comments; private items only when logic is non-obvious |
|
||||||
|
|
||||||
|
### 2.2 Rust 最佳实践 / Rust Best Practices
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ✅ 正确 / Correct
|
||||||
|
fn get_user(id: i64) -> AppResult<User> {
|
||||||
|
let user = db.find_user(id).await?; // 使用 ? 传播错误
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 错误 / Incorrect
|
||||||
|
fn get_user(id: i64) -> User {
|
||||||
|
db.find_user(id).await.unwrap() // 禁止 unwrap()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|-----------|---------------------------------------------------------------------------------------------|
|
||||||
|
| 错误传播 | Use `?` operator for error propagation; never use `unwrap()` or `expect()` in non-test code |
|
||||||
|
| `unsafe` | Avoid `unsafe` blocks; if necessary, add a `// SAFETY:` comment explaining why |
|
||||||
|
| `clone()` | Minimize `clone()` usage; prefer references or `Rc`/`Arc` for shared ownership |
|
||||||
|
| 魔法数字 | No magic numbers; define named constants with `const` |
|
||||||
|
| 硬编码字符串 | No hardcoded strings for config/status; use enums or constants |
|
||||||
|
| 死代码 | Remove dead code; don't leave commented-out code blocks |
|
||||||
|
| 未完成代码 | Don't commit `unimplemented!()`, `todo!()`, or `FIXME` without a tracking issue |
|
||||||
|
|
||||||
|
### 2.3 导入规范 / Import Guidelines
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 标准库 → 第三方 crate → 本地模块
|
||||||
|
// stdlib → third-party crates → local modules
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::common::Status;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 禁止模式 / Forbidden Patterns
|
||||||
|
|
||||||
|
以下代码模式在项目中严格禁止:
|
||||||
|
|
||||||
|
The following code patterns are strictly forbidden in this project:
|
||||||
|
|
||||||
|
| 禁止项 / Forbidden | 说明 / Reason |
|
||||||
|
|-------------------------------|------------------------------------------------|
|
||||||
|
| `// ── xxxx ──────────` | 禁止使用此类分隔线注释;使用 `// Section: xxx` 格式替代 |
|
||||||
|
| `unwrap()` / `expect()` (非测试) | 在非测试代码中禁止使用;使用 `?` 或 `unwrap_or` 等安全替代 |
|
||||||
|
| `panic!()` / `unreachable!()` | 除极少数不可能到达的分支外禁止使用;使用 `AppError` 替代 |
|
||||||
|
| 未处理的 `todo!()` | 不得提交包含 `todo!()` 的代码,除非有对应的 issue 追踪 |
|
||||||
|
| 注释掉的代码 | 不得提交被注释的代码块;使用 Git 历史追溯 |
|
||||||
|
| 过深嵌套 (≥4层) | 使用 early return、`match`、`map`/`and_then` 扁平化逻辑 |
|
||||||
|
| 过长函数 (>50行) | 拆分为更小的、职责单一的函数 |
|
||||||
|
| 魔法数字 | 使用 `const` 定义命名常量 |
|
||||||
|
| 硬编码字符串 | 使用枚举或常量定义配置值/状态值 |
|
||||||
|
| 死代码 | 删除未使用的代码、导入和变量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 错误处理 / Error Handling
|
||||||
|
|
||||||
|
### 4.1 错误类型体系 / Error Type System
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 统一使用 AppError 和 AppResult
|
||||||
|
// Use AppError and AppResult consistently
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
pub async fn create_user(req: CreateUserReq) -> AppResult<User> {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **AppError**: 统一错误枚举,包含领域错误和外部库包装
|
||||||
|
- **AppResult<T>**: `Result<T, AppError>` 的类型别名
|
||||||
|
|
||||||
|
### 4.2 错误处理原则 / Error Handling Principles
|
||||||
|
|
||||||
|
| 原则 / Principle | 说明 / Description |
|
||||||
|
|----------------|---------------------------------------------------------------------------------|
|
||||||
|
| 显式处理 | Handle all errors explicitly; no silent failures |
|
||||||
|
| 用户友好 | Internal errors are logged and masked; user-facing messages should be helpful |
|
||||||
|
| 错误上下文 | Use `.context()` or `.map_err()` to add meaningful context to errors |
|
||||||
|
| 错误分类 | Domain errors (UserNotFound) vs Infrastructure errors (DatabaseError) |
|
||||||
|
| Postgres 映射 | Map Postgres error codes (23505 unique, 23503 FK, 23514 check) to HTTP statuses |
|
||||||
|
|
||||||
|
### 4.3 错误日志格式 / Error Logging Format
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 记录错误时包含完整上下文
|
||||||
|
// Log errors with full context
|
||||||
|
tracing::error!(
|
||||||
|
error = %err,
|
||||||
|
user_id = %user_id,
|
||||||
|
operation = "create_user",
|
||||||
|
"Failed to create user"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 错误恢复策略 / Error Recovery
|
||||||
|
|
||||||
|
| 场景 / Scenario | 策略 / Strategy |
|
||||||
|
|---------------|---------------|
|
||||||
|
| 数据库连接失败 | 重试 + 降级到只读模式 |
|
||||||
|
| 外部服务超时 | 断路器 + 降级响应 |
|
||||||
|
| 缓存 miss | 回退到数据库查询 |
|
||||||
|
| 队列积压 | 背压控制 + 告警 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 安全规范 / Security
|
||||||
|
|
||||||
|
### 5.1 基础安全 / Basic Security
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|-----------|---------------------------------------------------------------|
|
||||||
|
| 密钥管理 | Never hardcode secrets or API keys; use environment variables |
|
||||||
|
| 输入验证 | Always validate and sanitize user input |
|
||||||
|
| SQL 注入 | Use parameterized queries (sqlx handles this automatically) |
|
||||||
|
| XSS 防护 | Escape output; use Content-Security-Policy headers |
|
||||||
|
| CSRF 防护 | Use CSRF tokens for state-changing operations |
|
||||||
|
| 密码安全 | Argon2 hashing with session-scoped RSA-2048 OAEP-SHA256 |
|
||||||
|
| 2FA | TOTP with HMAC-SHA1, base32 secrets, backup codes |
|
||||||
|
|
||||||
|
### 5.2 OWASP Top 10 防护 / OWASP Top 10 Protection
|
||||||
|
|
||||||
|
| 风险 / Risk | 防护措施 / Mitigation |
|
||||||
|
|-----------|------------------------------------------------------|
|
||||||
|
| 注入 | Parameterized queries, input validation |
|
||||||
|
| 失效认证 | Strong password policy, 2FA, session management |
|
||||||
|
| 敏感数据暴露 | Encryption at rest and in transit, data masking |
|
||||||
|
| XML 外部实体 | Disable XML external entity processing |
|
||||||
|
| 失效访问控制 | Role-based access control, resource ownership checks |
|
||||||
|
| 安全配置错误 | Secure defaults, environment-based config |
|
||||||
|
| XSS | Output encoding, CSP headers |
|
||||||
|
| 不安全反序列化 | Validate serialized data, use safe formats |
|
||||||
|
| 使用含漏洞组件 | Regular dependency updates, `cargo audit` |
|
||||||
|
| 日志和监控不足 | Comprehensive logging, alerting |
|
||||||
|
|
||||||
|
### 5.3 企业级安全 / Enterprise Security
|
||||||
|
|
||||||
|
| 要求 / Requirement | 说明 / Description |
|
||||||
|
|------------------|----------------------------------------------------------------------|
|
||||||
|
| 安全审计日志 | Log all sensitive operations with actor, action, resource, timestamp |
|
||||||
|
| 访问控制 | Implement RBAC/ABAC; check permissions at service layer |
|
||||||
|
| 数据脱敏 | Mask PII in logs; encrypt sensitive fields in database |
|
||||||
|
| 依赖安全 | Run `cargo audit` in CI; review new dependencies |
|
||||||
|
| 安全头 | Set HSTS, X-Frame-Options, X-Content-Type-Options, etc. |
|
||||||
|
| 速率限制 | Implement rate limiting for auth endpoints and API calls |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据库规范 / Database
|
||||||
|
|
||||||
|
### 6.1 基础规范 / Basic Rules
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|-----------|----------------------------------------------------------------------|
|
||||||
|
| 参数化查询 | Always use parameterized queries (sqlx does this by default) |
|
||||||
|
| 事务管理 | Use `ServiceContext::run_in_transaction()` for multi-step operations |
|
||||||
|
| 读写分离 | Use `AppDatabase` read/write pool methods appropriately |
|
||||||
|
| 迁移规范 | All schema changes must go through migration files in `migrate/` |
|
||||||
|
|
||||||
|
### 6.2 性能优化 / Performance Optimization
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|-----------|----------------------------------------------------------------------|
|
||||||
|
| N+1 防护 | Use `JOIN` or batch queries instead of N+1 patterns |
|
||||||
|
| 批量操作 | Use `INSERT ... ON CONFLICT`, `UPDATE ... FROM`, bulk operations |
|
||||||
|
| 索引规范 | Add indexes for frequently queried columns; document index rationale |
|
||||||
|
| 查询分析 | Use `EXPLAIN ANALYZE` to verify query plans for complex queries |
|
||||||
|
| 连接池 | Configure pool sizes based on workload; monitor connection usage |
|
||||||
|
| 慢查询 | Log queries >100ms; investigate and optimize |
|
||||||
|
|
||||||
|
### 6.3 数据一致性 / Data Consistency
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|-----------|-----------------------------------------------------------|
|
||||||
|
| 事务边界 | Keep transactions short; avoid long-running transactions |
|
||||||
|
| 幂等性 | Design operations to be idempotent where possible |
|
||||||
|
| 乐观锁 | Use version columns for concurrent update protection |
|
||||||
|
| 外键约束 | Use database-level foreign keys for referential integrity |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API 设计规范 / API Design
|
||||||
|
|
||||||
|
### 7.1 RESTful 规范 / RESTful Conventions
|
||||||
|
|
||||||
|
| 规则 / Rule | 示例 / Example |
|
||||||
|
|-----------|-----------------------------------------------------------------------------------------|
|
||||||
|
| 资源命名 | `/api/v1/workspaces/{id}/repos` (复数名词) |
|
||||||
|
| HTTP 方法 | GET (读取), POST (创建), PUT/PATCH (更新), DELETE (删除) |
|
||||||
|
| 状态码 | 200 (成功), 201 (创建), 204 (无内容), 400 (客户端错误), 401 (未认证), 403 (禁止), 404 (未找到), 500 (服务器错误) |
|
||||||
|
| 版本管理 | URL path versioning: `/api/v1/...` |
|
||||||
|
|
||||||
|
### 7.2 响应格式 / Response Format
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 统一响应类型
|
||||||
|
// Unified response types
|
||||||
|
ApiResponse<T> // 单个数据 / Single payload
|
||||||
|
ApiListResponse<T> // 分页列表 / Paginated list { data, total, page, per_page }
|
||||||
|
ApiEmptyResponse // 空响应 / Empty response
|
||||||
|
ApiErrorResponse // 错误响应 / Error response { code, message, details }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 OpenAPI 文档 / OpenAPI Documentation
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 每个端点必须添加 OpenAPI 注解
|
||||||
|
// Every endpoint must have OpenAPI annotations
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/login",
|
||||||
|
request_body = LoginReq,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Login successful", body = ApiResponse<LoginResp>),
|
||||||
|
(status = 401, description = "Invalid credentials", body = ApiErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "auth"
|
||||||
|
)]
|
||||||
|
pub async fn login(...) -> HttpResponse { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 API 治理 / API Governance
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|---|---|
|
||||||
|
| 请求验证 | Validate all request bodies and query parameters |
|
||||||
|
| 速率限制 | Apply rate limiting to auth and resource-intensive endpoints |
|
||||||
|
| 幂等性 | POST operations with same idempotency key should produce same result |
|
||||||
|
| 缓存策略 | Use ETag/Last-Modified for cacheable resources |
|
||||||
|
| 错误码体系 | Consistent error codes across all endpoints |
|
||||||
|
| 分页 | Default page size 20, max 100; use cursor-based pagination for large datasets |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 日志与可观测性 / Logging & Observability
|
||||||
|
|
||||||
|
### 8.1 日志规范 / Logging Standards
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 使用 tracing crate 进行结构化日志
|
||||||
|
// Use tracing crate for structured logging
|
||||||
|
use tracing::{info, warn, error, debug, instrument};
|
||||||
|
|
||||||
|
#[instrument(skip(db), fields(user_id = %req.user_id))]
|
||||||
|
pub async fn create_user(req: CreateUserReq) -> AppResult<User> {
|
||||||
|
info!("Creating new user");
|
||||||
|
// ...
|
||||||
|
error!(error = %err, "Failed to create user");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 级别 / Level | 用途 / Usage |
|
||||||
|
|---|---|
|
||||||
|
| `error` | 错误需要立即关注 / Errors requiring immediate attention |
|
||||||
|
| `warn` | 异常但可恢复的情况 / Abnormal but recoverable situations |
|
||||||
|
| `info` | 关键业务操作记录 / Key business operation records |
|
||||||
|
| `debug` | 开发调试信息 / Development debugging info |
|
||||||
|
| `trace` | 详细执行路径 / Detailed execution paths |
|
||||||
|
|
||||||
|
### 8.2 敏感信息脱敏 / Data Masking
|
||||||
|
|
||||||
|
| 数据类型 / Data Type | 脱敏规则 / Masking Rule |
|
||||||
|
|---|---|
|
||||||
|
| 密码 | 完全隐藏 / Never log |
|
||||||
|
| Token/密钥 | 只显示前 4 位 / Show first 4 chars only |
|
||||||
|
| 邮箱 | `u***@example.com` |
|
||||||
|
| IP 地址 | 保留网段 / Keep subnet |
|
||||||
|
| 个人信息 | 根据最小必要原则 / Minimum necessary principle |
|
||||||
|
|
||||||
|
### 8.3 性能指标 / Metrics
|
||||||
|
|
||||||
|
| 指标 / Metric | 说明 / Description |
|
||||||
|
|---|---|
|
||||||
|
| 请求延迟 | HTTP request latency (P50, P95, P99) |
|
||||||
|
| 错误率 | Error rate by endpoint and status code |
|
||||||
|
| 吞吐量 | Requests per second |
|
||||||
|
| 数据库连接 | Active/idle connections in pool |
|
||||||
|
| 缓存命中率 | Cache hit/miss ratio |
|
||||||
|
| 队列积压 | Queue depth and processing rate |
|
||||||
|
| 内存使用 | Heap usage, allocation rate |
|
||||||
|
| 活跃会话 | Active WebSocket sessions |
|
||||||
|
|
||||||
|
### 8.4 健康检查 / Health Checks
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 端点: GET /health
|
||||||
|
// Endpoint: GET /health
|
||||||
|
{
|
||||||
|
"status": "healthy", // healthy | degraded | unhealthy
|
||||||
|
"version": "1.0.0",
|
||||||
|
"uptime": 3600,
|
||||||
|
"checks": {
|
||||||
|
"database": { "status": "up", "latency_ms": 5 },
|
||||||
|
"redis": { "status": "up", "latency_ms": 2 },
|
||||||
|
"nats": { "status": "up", "latency_ms": 1 },
|
||||||
|
"etcd": { "status": "up", "latency_ms": 3 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 告警规则 / Alerting Rules
|
||||||
|
|
||||||
|
| 条件 / Condition | 级别 / Level |
|
||||||
|
|---|---|
|
||||||
|
| 错误率 > 5% | Critical |
|
||||||
|
| P99 延迟 > 500ms | Warning |
|
||||||
|
| 数据库连接池 > 80% | Warning |
|
||||||
|
| 队列积压 > 1000 | Critical |
|
||||||
|
| 内存使用 > 85% | Warning |
|
||||||
|
| 健康检查失败 | Critical |
|
||||||
|
|
||||||
|
### 8.6 请求链路追踪 / Request Tracing
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 每个请求分配唯一 trace_id
|
||||||
|
// Each request gets a unique trace_id
|
||||||
|
tracing::info!(
|
||||||
|
trace_id = %request_id,
|
||||||
|
user_id = %session.user_id,
|
||||||
|
method = %req.method(),
|
||||||
|
path = %req.path(),
|
||||||
|
"Request started"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 性能规范 / Performance
|
||||||
|
|
||||||
|
### 9.1 SLA 目标 / SLA Targets
|
||||||
|
|
||||||
|
| 指标 / Metric | 目标 / Target |
|
||||||
|
|---|---|
|
||||||
|
| 可用性 | 99.9% (每月宕机 <43 分钟) |
|
||||||
|
| P50 延迟 | <50ms |
|
||||||
|
| P95 延迟 | <200ms |
|
||||||
|
| P99 延迟 | <500ms |
|
||||||
|
| 错误率 | <0.1% |
|
||||||
|
| 数据库查询 | <100ms (常规查询) |
|
||||||
|
| 缓存命中率 | >90% |
|
||||||
|
|
||||||
|
### 9.2 性能原则 / Performance Principles
|
||||||
|
|
||||||
|
| 原则 / Principle | 说明 / Description |
|
||||||
|
|---|---|
|
||||||
|
| 基准测试 | Establish performance baselines before optimization |
|
||||||
|
| 测量优先 | Profile before optimizing; don't guess |
|
||||||
|
| 渐进优化 | Optimize iteratively; measure impact of each change |
|
||||||
|
| 容量规划 | Plan for 3x current load |
|
||||||
|
|
||||||
|
### 9.3 优化策略 / Optimization Strategies
|
||||||
|
|
||||||
|
| 场景 / Scenario | 策略 / Strategy |
|
||||||
|
|---|---|
|
||||||
|
| 热点查询 | Add caching (L1 + L2) |
|
||||||
|
| 大量读取 | Use read replicas |
|
||||||
|
| 批量操作 | Batch database operations |
|
||||||
|
| 高并发 | Use connection pooling, async I/O |
|
||||||
|
| 大数据量 | Use cursor-based pagination |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 测试规范 / Testing
|
||||||
|
|
||||||
|
### 10.1 基础要求 / Basic Requirements
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|---|---|
|
||||||
|
| 新功能 | All new features must have unit tests |
|
||||||
|
| Bug 修复 | Bug fixes must include regression tests |
|
||||||
|
| 关键路径 | Critical business logic must have integration tests |
|
||||||
|
| 测试隔离 | Tests must be independent and not depend on execution order |
|
||||||
|
|
||||||
|
### 10.2 测试命令 / Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test # 运行所有测试 / Run all tests
|
||||||
|
cargo test -- <test_name> # 按名称运行 / Run by name
|
||||||
|
cargo test lru::tests # 运行特定模块 / Run module tests
|
||||||
|
cargo test -- --nocapture # 显示输出 / Show output
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 测试命名 / Test Naming
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_user_with_valid_input() { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_user_with_duplicate_email_returns_error() { ... }
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_async_operation_handles_timeout() { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Git 规范 / Git Workflow
|
||||||
|
|
||||||
|
### 11.1 提交信息格式 / Commit Message Format
|
||||||
|
|
||||||
|
使用 Angular 风格,全部英文:
|
||||||
|
|
||||||
|
Use Angular style, all English:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | 说明 / Description |
|
||||||
|
|---|---|
|
||||||
|
| `feat` | 新功能 / New feature |
|
||||||
|
| `fix` | Bug 修复 / Bug fix |
|
||||||
|
| `refactor` | 重构 / Code refactoring |
|
||||||
|
| `docs` | 文档 / Documentation |
|
||||||
|
| `test` | 测试 / Tests |
|
||||||
|
| `chore` | 构建/工具 / Build/tooling |
|
||||||
|
| `perf` | 性能优化 / Performance improvement |
|
||||||
|
| `style` | 代码格式 / Code formatting |
|
||||||
|
| `ci` | CI/CD 相关 / CI/CD changes |
|
||||||
|
|
||||||
|
**示例 / Examples:**
|
||||||
|
```
|
||||||
|
feat(auth): add 2FA login support
|
||||||
|
fix(api): resolve race condition in user creation
|
||||||
|
refactor(service): extract common validation logic
|
||||||
|
docs(readme): update API documentation
|
||||||
|
test(cache): add unit tests for LRU eviction
|
||||||
|
chore(deps): update sqlx to 0.8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 提交原则 / Commit Principles
|
||||||
|
|
||||||
|
| 原则 / Principle | 说明 / Description |
|
||||||
|
|---|---|
|
||||||
|
| 原子提交 | Each commit should address one concern |
|
||||||
|
| 完整性 | Each commit should leave the codebase in a working state |
|
||||||
|
| 禁止强制推送 | Never force push to main branch |
|
||||||
|
| 提交前检查 | Run `cargo check` and `cargo test` before committing |
|
||||||
|
|
||||||
|
### 11.3 分支策略 / Branch Strategy
|
||||||
|
|
||||||
|
| 分支 / Branch | 用途 / Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `main` | 生产就绪代码 / Production-ready code |
|
||||||
|
| `feat/*` | 功能开发 / Feature development |
|
||||||
|
| `fix/*` | Bug 修复 / Bug fixes |
|
||||||
|
| `release/*` | 发布准备 / Release preparation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 工作流程 / Workflow
|
||||||
|
|
||||||
|
### 12.1 开发流程 / Development Process
|
||||||
|
|
||||||
|
1. **理解先于编写** — Read before write; understand context first
|
||||||
|
2. **最小变更** — Minimal changes; don't refactor unrelated code
|
||||||
|
3. **验证变更** — Verify after changes; run tests or check output
|
||||||
|
4. **文档同步** — Update documentation when changing public APIs
|
||||||
|
|
||||||
|
### 12.2 AI 助手工作规范 / AI Assistant Guidelines
|
||||||
|
|
||||||
|
| 规则 / Rule | 说明 / Description |
|
||||||
|
|---|---|
|
||||||
|
| 先读后写 | Always read existing code before making changes |
|
||||||
|
| 最小侵入 | Make minimal changes; don't refactor unrelated code |
|
||||||
|
| 验证结果 | Run `cargo check` or `cargo test` after changes |
|
||||||
|
| 解释变更 | Explain what you changed and why |
|
||||||
|
| 询问不确定 | Ask when unsure about requirements |
|
||||||
|
|
||||||
|
### 12.3 常用命令 / Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build # 构建 / Build
|
||||||
|
cargo check # 快速检查 / Quick check
|
||||||
|
cargo test # 运行测试 / Run tests
|
||||||
|
cargo clippy # Lint 检查 / Lint checks
|
||||||
|
cargo fmt # 格式化 / Format code
|
||||||
|
cargo doc --no-deps # 生成文档 / Build docs
|
||||||
|
cargo machete # 检查未使用依赖 / Check unused deps
|
||||||
|
cargo run --bin gen_openapi # 生成 OpenAPI / Generate OpenAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 架构决策记录 / ADR
|
||||||
|
|
||||||
|
架构决策记录存放在 `docs/adr/` 目录下,使用 Markdown 格式。
|
||||||
|
|
||||||
|
Architecture Decision Records are stored in `docs/adr/` directory in Markdown format.
|
||||||
|
|
||||||
|
### 索引 / Index
|
||||||
|
|
||||||
|
| ADR | 标题 / Title | 状态 / Status |
|
||||||
|
|---|---|---|
|
||||||
|
| [ADR-001](docs/adr/001-choice-of-web-framework.md) | 选择 Actix-web 作为 Web 框架 | Accepted |
|
||||||
|
| [ADR-002](docs/adr/002-two-tier-caching.md) | 两级缓存架构 (L1 LRU + L2 Redis) | Accepted |
|
||||||
|
| [ADR-003](docs/adr/003-nats-for-messaging.md) | 使用 NATS JetStream 作为消息队列 | Accepted |
|
||||||
|
| [ADR-004](docs/adr/004-etcd-for-discovery.md) | 使用 etcd 进行服务发现 | Accepted |
|
||||||
|
| [ADR-005](docs/adr/005-error-handling-strategy.md) | 统一错误处理策略 | Accepted |
|
||||||
|
|
||||||
|
### ADR 模板 / ADR Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# ADR-NNN: 标题 / Title
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
Accepted | Superseded | Deprecated
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
描述问题背景 / Describe the context
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
描述做出的决策 / Describe the decision
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
描述正面和负面影响 / Describe positive and negative impacts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 审查清单 / Review Checklist
|
||||||
|
|
||||||
|
### 代码审查 / Code Review
|
||||||
|
|
||||||
|
- [ ] 代码风格符合项目规范 / Code style follows project conventions
|
||||||
|
- [ ] 没有使用禁止模式 / No forbidden patterns used
|
||||||
|
- [ ] 错误处理完整 / Error handling is complete
|
||||||
|
- [ ] 安全考虑已处理 / Security considerations addressed
|
||||||
|
- [ ] 性能影响已评估 / Performance impact assessed
|
||||||
|
- [ ] 测试已添加 / Tests are added
|
||||||
|
- [ ] 文档已更新 / Documentation is updated
|
||||||
|
|
||||||
|
### PR 审查 / PR Review
|
||||||
|
|
||||||
|
- [ ] 提交信息符合 Angular 风格 / Commit messages follow Angular style
|
||||||
|
- [ ] 每个提交只关注一个问题 / Each commit addresses one concern
|
||||||
|
- [ ] 变更范围合理 / Change scope is reasonable
|
||||||
|
- [ ] 没有遗留的 TODO/FIXME / No leftover TODO/FIXME
|
||||||
|
- [ ] CI 检查通过 / CI checks pass
|
||||||
|
|
||||||
|
### 发布前审查 / Pre-release Review
|
||||||
|
|
||||||
|
- [ ] 所有测试通过 / All tests pass
|
||||||
|
- [ ] 性能测试完成 / Performance tests completed
|
||||||
|
- [ ] 安全扫描通过 / Security scan passed
|
||||||
|
- [ ] 文档完整 / Documentation is complete
|
||||||
|
- [ ] 变更日志已更新 / Changelog is updated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 / Appendix
|
||||||
|
|
||||||
|
### 项目架构速查 / Quick Architecture Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
appks — 协作开发平台后端 / Collaborative Development Platform Backend
|
||||||
|
|
||||||
|
config/ → 环境配置 / Environment configuration
|
||||||
|
models/ → 数据模型 / Data models (sqlx FromRow)
|
||||||
|
service/ → 业务逻辑 / Business logic (AppService)
|
||||||
|
api/ → HTTP 端点 / HTTP endpoints
|
||||||
|
immediate/ → 实时 IM / Real-time IM (WebSocket)
|
||||||
|
cache/ → 两级缓存 / Two-tier cache (L1 + L2)
|
||||||
|
storage/ → 对象存储 / Object storage (S3)
|
||||||
|
queue/ → 消息队列 / Message queue (NATS)
|
||||||
|
etcd/ → 服务发现 / Service discovery
|
||||||
|
session/ → 会话管理 / Session management
|
||||||
|
pb/ → gRPC 客户端 / gRPC client stubs
|
||||||
|
proto/ → Protobuf 定义 / Protobuf definitions
|
||||||
|
migrate/ → 数据库迁移 / Database migrations
|
||||||
|
error.rs → 统一错误类型 / Unified error types
|
||||||
|
```
|
||||||
|
|
||||||
|
### 基础设施速查 / Infrastructure Quick Reference
|
||||||
|
|
||||||
|
| 服务 / Service | 用途 / Purpose | 协议 / Protocol |
|
||||||
|
|--------------|-----------------------------------------|---------------|
|
||||||
|
| Postgres | 主数据库 / Primary database | sqlx |
|
||||||
|
| Redis | 缓存/会话/限流 / Cache/sessions/rate limiting | redis + r2d2 |
|
||||||
|
| etcd | 服务发现 / Service discovery | etcd-client |
|
||||||
|
| NATS | 消息队列 / Message queue | async-nats |
|
||||||
|
| S3/MinIO | 对象存储 / Object storage | object_store |
|
||||||
|
| Qdrant | 向量数据库 / Vector DB | config only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This document is maintained by the development team. For questions or suggestions, please open an issue.*
|
||||||
Generated
+54
@@ -349,6 +349,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-multipart",
|
"actix-multipart",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"arc-swap",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -362,6 +363,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"hkdf 0.12.4",
|
"hkdf 0.12.4",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
|
"jsonwebtoken",
|
||||||
"object_store",
|
"object_store",
|
||||||
"prost",
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
@@ -378,6 +380,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tonic",
|
"tonic",
|
||||||
|
"tonic-health",
|
||||||
"tonic-prost",
|
"tonic-prost",
|
||||||
"tonic-prost-build",
|
"tonic-prost-build",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2420,6 +2423,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "9.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"js-sys",
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"simple_asn1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "language-tags"
|
name = "language-tags"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2959,6 +2977,16 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -4118,6 +4146,18 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simple_asn1"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -4597,6 +4637,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4675,6 +4716,19 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic-health"
|
||||||
|
version = "0.14.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fcfab99db777fba2802f0dfa861d1628d1ae916fb199d29819941f139ae85082"
|
||||||
|
dependencies = [
|
||||||
|
"prost",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tonic",
|
||||||
|
"tonic-prost",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tonic-prost"
|
name = "tonic-prost"
|
||||||
version = "0.14.6"
|
version = "0.14.6"
|
||||||
|
|||||||
+25
-22
@@ -16,20 +16,20 @@ path = "main.rs"
|
|||||||
name = "gen_openapi"
|
name = "gen_openapi"
|
||||||
path = "gen_openapi.rs"
|
path = "gen_openapi.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sqlx = { version = "0.9.0", features = ["postgres","runtime-tokio","chrono","uuid","json","migrate"] }
|
sqlx = { version = "0.9", features = ["postgres","runtime-tokio","chrono","uuid","json","migrate"] }
|
||||||
tokio = { version = "1.52.3", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1.0.150", features = [] }
|
serde_json = { version = "1", features = [] }
|
||||||
chrono = { version = "0.4.19", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1.23.1", features = ["serde","v4","v7","v5"] }
|
uuid = { version = "1", features = ["serde","v4","v7","v5"] }
|
||||||
reqwest = { version = "0.13.4", features = ["json"] }
|
reqwest = { version = "0.13", features = ["json"] }
|
||||||
tracing = { version = "0.1.44", features = [] }
|
tracing = { version = "0.1", features = [] }
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
redis = { version = "1.2.1", features = ["cluster","cluster-async","aio","tokio-comp","connection-manager"] }
|
redis = { version = "1", features = ["cluster","cluster-async","aio","tokio-comp","connection-manager"] }
|
||||||
dashmap = "6.1"
|
dashmap = "6"
|
||||||
object_store = { version = "0.13.2", features = ["tokio","aws","cloud"] }
|
object_store = { version = "0.13", features = ["tokio","aws","cloud"] }
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rsa = "0.9"
|
rsa = "0.9"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
@@ -37,22 +37,25 @@ hkdf = "0.12"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
arc-swap = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
captcha-rs = "0.5"
|
captcha-rs = "0.5"
|
||||||
tonic = { version = "0.14.6", features = ["transport", "channel"] }
|
tonic = { version = "0.14", features = ["transport", "channel"] }
|
||||||
prost = "0.14.3"
|
prost = "0.14"
|
||||||
prost-types = "0.14.3"
|
prost-types = "0.14"
|
||||||
tonic-prost = "0.14.6"
|
tonic-prost = "0.14"
|
||||||
|
tonic-health = "0.14.6"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
etcd-client = { version = "0.18.0", features = ["tls"] }
|
etcd-client = { version = "0.18", features = ["tls"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = { version = "0.1", features = ["net"] }
|
||||||
async-nats = "0.49"
|
async-nats = "0.49"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
utoipa = { version = "5.5.0", features = ["uuid","chrono","actix_extras","decimal","macros"]}
|
utoipa = { version = "5", features = ["uuid","chrono","actix_extras","decimal","macros"]}
|
||||||
actix-web = { version = "4", features = ["secure-cookies"] }
|
actix-web = { version = "4", features = ["secure-cookies"] }
|
||||||
actix-multipart = "0.7"
|
actix-multipart = "0.7"
|
||||||
hex = "0.4.3"
|
hex = "0.4"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tonic-prost-build = "0.14.6"
|
tonic-prost-build = "0.14"
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
FROM rust:1.96-bookworm AS chef
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
protobuf-compiler libprotobuf-dev mold clang && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN cargo install cargo-chef
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release --bin appks && \
|
||||||
|
strip target/release/appks
|
||||||
|
|
||||||
|
FROM ubuntu:26.04
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=builder /app/target/release/appks /usr/local/bin/appks
|
||||||
|
|
||||||
|
ENV APP_HTTP_HOST=0.0.0.0
|
||||||
|
ENV APP_HTTP_PORT=8000
|
||||||
|
ENV APP_RPC_SELF_HOST=0.0.0.0
|
||||||
|
ENV APP_RPC_SELF_PORT=50049
|
||||||
|
|
||||||
|
EXPOSE 8000 50049
|
||||||
|
ENTRYPOINT ["appks"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM ubuntu:26.04
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY target/release/appks /usr/local/bin/appks
|
||||||
|
|
||||||
|
ENV APP_HTTP_HOST=0.0.0.0
|
||||||
|
ENV APP_HTTP_PORT=8000
|
||||||
|
ENV APP_RPC_SELF_HOST=0.0.0.0
|
||||||
|
ENV APP_RPC_SELF_PORT=50049
|
||||||
|
|
||||||
|
EXPOSE 8000 50049
|
||||||
|
|
||||||
|
ENTRYPOINT ["appks"]
|
||||||
@@ -16,6 +16,7 @@ pub mod rsa;
|
|||||||
pub mod verify_2fa;
|
pub mod verify_2fa;
|
||||||
pub mod verify_email;
|
pub mod verify_email;
|
||||||
pub mod verify_reset_password;
|
pub mod verify_reset_password;
|
||||||
|
pub mod ws_token;
|
||||||
|
|
||||||
use actix_web::web;
|
use actix_web::web;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.route("/login", web::post().to(login::handle))
|
.route("/login", web::post().to(login::handle))
|
||||||
.route("/logout", web::post().to(logout::handle))
|
.route("/logout", web::post().to(logout::handle))
|
||||||
.route("/me", web::get().to(me::handle))
|
.route("/me", web::get().to(me::handle))
|
||||||
|
.route("/ws-token", web::post().to(ws_token::handle))
|
||||||
.route(
|
.route(
|
||||||
"/register/email-code",
|
"/register/email-code",
|
||||||
web::post().to(register_email_code::handle),
|
web::post().to(register_email_code::handle),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use actix_web::{HttpResponse, web};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::api::response::{ApiErrorResponse, ApiResponse};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
/// Response payload for `POST /auth/ws-token`.
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct WsTokenResponse {
|
||||||
|
/// Short-lived JWT prefixed with "Bearer " for use in the Socket.IO CONNECT auth packet.
|
||||||
|
pub token: String,
|
||||||
|
/// Unix timestamp (seconds) when the token expires.
|
||||||
|
pub expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/ws-token",
|
||||||
|
tag = "Auth",
|
||||||
|
operation_id = "authWsToken",
|
||||||
|
summary = "Issue a short-lived WebSocket token",
|
||||||
|
description = "Issue a short-lived JWT (30 minutes) scoped to IM WebSocket access. \
|
||||||
|
The token is signed by the appks signing key and can be verified by imks either \
|
||||||
|
locally (via cached signing keys) or via RPC. The returned token should be passed \
|
||||||
|
as `{ token: <value> }` in the Socket.IO CONNECT auth packet. Requires an \
|
||||||
|
authenticated session.",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Token issued successfully.", body = ApiResponse<WsTokenResponse>),
|
||||||
|
(status = 401, description = "The current session is unauthenticated or the login state has expired.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Token issuance or Redis write failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let user_uid = session.user().ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
let issued = service
|
||||||
|
.internal_auth
|
||||||
|
.issue_token(
|
||||||
|
&user_uid.to_string(),
|
||||||
|
1800, // 30-minute TTL (frontend refreshes every 25 min)
|
||||||
|
vec!["im:read".into(), "im:write".into()],
|
||||||
|
HashMap::new(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(WsTokenResponse {
|
||||||
|
token: format!("Bearer {}", issued.access_token),
|
||||||
|
expires_at: issued.expires_at,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use actix_web::{HttpResponse, web};
|
use actix_web::{HttpResponse, web};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::api::response::ApiResponse;
|
use crate::api::response::ApiResponse;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -7,66 +8,58 @@ use crate::service::AppService;
|
|||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct IssueApiKeyRequest {
|
pub struct IssueTokenRequest {
|
||||||
pub service_name: String,
|
pub user_id: String,
|
||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
pub ttl_hours: Option<u64>,
|
pub ttl_hours: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
pub struct IssueApiKeyResponse {
|
pub struct IssueTokenResponse {
|
||||||
pub api_key: String,
|
pub access_token: String,
|
||||||
pub service_name: String,
|
pub refresh_token: String,
|
||||||
pub service_id: String,
|
|
||||||
pub scopes: Vec<String>,
|
|
||||||
pub expires_at: i64,
|
pub expires_at: i64,
|
||||||
|
pub key_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/v1/internal/api-keys",
|
path = "/api/v1/internal/tokens",
|
||||||
tag = "Internal",
|
tag = "Internal",
|
||||||
operation_id = "internalIssueApiKey",
|
operation_id = "internalIssueToken",
|
||||||
request_body = IssueApiKeyRequest,
|
request_body = IssueTokenRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "API key issued", body = ApiResponse<IssueApiKeyResponse>),
|
(status = 200, description = "JWT token issued", body = ApiResponse<IssueTokenResponse>),
|
||||||
(status = 401, description = "Authentication required"),
|
(status = 401, description = "Authentication required"),
|
||||||
(status = 403, description = "Admin permission required"),
|
(status = 403, description = "Admin permission required"),
|
||||||
),
|
),
|
||||||
security(("session_cookie" = []))
|
security(("session_cookie" = []))
|
||||||
)]
|
)]
|
||||||
pub async fn issue_api_key(
|
pub async fn issue_token(
|
||||||
session: Session,
|
session: Session,
|
||||||
service: web::Data<AppService>,
|
service: web::Data<AppService>,
|
||||||
body: web::Json<IssueApiKeyRequest>,
|
body: web::Json<IssueTokenRequest>,
|
||||||
) -> Result<HttpResponse, AppError> {
|
) -> Result<HttpResponse, AppError> {
|
||||||
let user_uid = session.user().ok_or(AppError::Unauthorized)?;
|
let _user_uid = session.user().ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
let is_owner: bool = sqlx::query_scalar(
|
let ttl_secs = body.ttl_hours.unwrap_or(1) * 3600;
|
||||||
"SELECT EXISTS(SELECT 1 FROM workspace WHERE owner_id = $1 AND deleted_at IS NULL)",
|
|
||||||
)
|
|
||||||
.bind(user_uid)
|
|
||||||
.fetch_one(service.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
if !is_owner {
|
let tokens = service
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"workspace owner permission required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let ttl_secs = body.ttl_hours.map(|h| h * 3600);
|
|
||||||
let (api_key, identity) = service
|
|
||||||
.internal_auth
|
.internal_auth
|
||||||
.issue_api_key(&body.service_name, body.scopes.clone(), ttl_secs)
|
.issue_token(
|
||||||
|
&body.user_id,
|
||||||
|
ttl_secs,
|
||||||
|
body.scopes.clone(),
|
||||||
|
body.extra.clone(),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(ApiResponse::new(IssueApiKeyResponse {
|
Ok(HttpResponse::Ok().json(ApiResponse::new(IssueTokenResponse {
|
||||||
api_key,
|
access_token: tokens.access_token,
|
||||||
service_name: identity.service_name,
|
refresh_token: tokens.refresh_token,
|
||||||
service_id: identity.service_id,
|
expires_at: tokens.expires_at,
|
||||||
scopes: identity.scopes,
|
key_id: tokens.key_id,
|
||||||
expires_at: identity.expires_at,
|
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,6 +5,6 @@ use actix_web::web;
|
|||||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
web::scope("/internal")
|
web::scope("/internal")
|
||||||
.route("/api-keys", web::post().to(issue_api_key::issue_api_key)),
|
.route("/tokens", web::post().to(issue_api_key::issue_token)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use crate::api::auth::regenerate_2fa_backup_codes::{
|
|||||||
Regenerate2FABackupCodesRequest, Regenerate2FABackupCodesResponse,
|
Regenerate2FABackupCodesRequest, Regenerate2FABackupCodesResponse,
|
||||||
};
|
};
|
||||||
use crate::api::auth::register::RegisterResponse;
|
use crate::api::auth::register::RegisterResponse;
|
||||||
|
use crate::api::auth::ws_token::WsTokenResponse;
|
||||||
use crate::api::issue::lock::LockIssueParams;
|
use crate::api::issue::lock::LockIssueParams;
|
||||||
use crate::api::issue::subscribers::MuteIssueParams;
|
use crate::api::issue::subscribers::MuteIssueParams;
|
||||||
use crate::api::issue::transfer::TransferIssueParams;
|
use crate::api::issue::transfer::TransferIssueParams;
|
||||||
@@ -174,6 +175,7 @@ use crate::service::im::members::{InviteMemberParams, UpdateMemberParams};
|
|||||||
crate::api::auth::disable_2fa::handle,
|
crate::api::auth::disable_2fa::handle,
|
||||||
crate::api::auth::regenerate_2fa_backup_codes::handle,
|
crate::api::auth::regenerate_2fa_backup_codes::handle,
|
||||||
crate::api::auth::change_password::change_password,
|
crate::api::auth::change_password::change_password,
|
||||||
|
crate::api::auth::ws_token::handle,
|
||||||
// User
|
// User
|
||||||
crate::api::user::get_account::get_account,
|
crate::api::user::get_account::get_account,
|
||||||
crate::api::user::update_account::update_account,
|
crate::api::user::update_account::update_account,
|
||||||
@@ -839,6 +841,8 @@ use crate::service::im::members::{InviteMemberParams, UpdateMemberParams};
|
|||||||
NotifyUpdateTemplateParams,
|
NotifyUpdateTemplateParams,
|
||||||
// Auth additions
|
// Auth additions
|
||||||
ChangePasswordParams,
|
ChangePasswordParams,
|
||||||
|
ApiResponse<WsTokenResponse>,
|
||||||
|
WsTokenResponse,
|
||||||
// User additions - Presence/Block/Follow
|
// User additions - Presence/Block/Follow
|
||||||
ApiResponse<UserPresence>,
|
ApiResponse<UserPresence>,
|
||||||
UserPresence,
|
UserPresence,
|
||||||
|
|||||||
@@ -1,29 +1,85 @@
|
|||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
use std::fs;
|
||||||
tonic_prost_build::configure()
|
use std::path::{Path, PathBuf};
|
||||||
.build_client(true)
|
|
||||||
.build_server(false)
|
|
||||||
.compile_protos(&["proto/email/email.proto"], &["proto/email"])?;
|
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
|
||||||
|
let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
|
||||||
|
fs::create_dir_all(&out_dir)?;
|
||||||
|
let email_dir = manifest_dir.join("proto/email");
|
||||||
|
let email_protos = proto_files(&email_dir)?;
|
||||||
|
for proto in &email_protos {
|
||||||
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
|
}
|
||||||
tonic_prost_build::configure()
|
tonic_prost_build::configure()
|
||||||
.build_client(true)
|
.build_client(true)
|
||||||
.build_server(false)
|
.build_server(false)
|
||||||
.compile_protos(
|
.out_dir(&out_dir)
|
||||||
&[
|
.compile_protos(&email_protos, &[email_dir])?;
|
||||||
"proto/git/oid.proto",
|
|
||||||
"proto/git/tagger.proto",
|
let git_dir = manifest_dir.join("proto/git");
|
||||||
"proto/git/repository.proto",
|
let git_protos = proto_files(&git_dir)?;
|
||||||
"proto/git/commit.proto",
|
for proto in &git_protos {
|
||||||
"proto/git/branch.proto",
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
"proto/git/tag.proto",
|
}
|
||||||
"proto/git/tree.proto",
|
tonic_prost_build::configure()
|
||||||
"proto/git/diff.proto",
|
.build_client(true)
|
||||||
"proto/git/merge.proto",
|
.build_server(false)
|
||||||
"proto/git/blame.proto",
|
.type_attribute(
|
||||||
"proto/git/archive.proto",
|
".",
|
||||||
"proto/git/pack.proto",
|
"#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]",
|
||||||
],
|
)
|
||||||
&["proto/git"],
|
.extern_path(".google.protobuf.Timestamp", "crate::pb::Timestamp")
|
||||||
)?;
|
.out_dir(&out_dir)
|
||||||
|
.compile_protos(&git_protos, &[git_dir])?;
|
||||||
|
|
||||||
|
// proto/core/ — JWT token service (server + client: appks serves, imks consumes)
|
||||||
|
let core_dir = manifest_dir.join("proto/core");
|
||||||
|
let core_protos = proto_files(&core_dir)?;
|
||||||
|
for proto in &core_protos {
|
||||||
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
|
}
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.build_client(true)
|
||||||
|
.build_server(true)
|
||||||
|
.out_dir(&out_dir)
|
||||||
|
.compile_protos(&core_protos, &[core_dir])?;
|
||||||
|
|
||||||
|
let this_dir = manifest_dir.join("proto/this");
|
||||||
|
let this_protos = proto_files(&this_dir)?;
|
||||||
|
for proto in &this_protos {
|
||||||
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
|
}
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.build_client(false)
|
||||||
|
.build_server(true)
|
||||||
|
.out_dir(&out_dir)
|
||||||
|
.compile_protos(&this_protos, &[this_dir])?;
|
||||||
|
|
||||||
|
let im_dir = manifest_dir.join("proto/this/im");
|
||||||
|
let im_protos = proto_files(&im_dir)?;
|
||||||
|
for proto in &im_protos {
|
||||||
|
println!("cargo:rerun-if-changed={}", proto.display());
|
||||||
|
}
|
||||||
|
tonic_prost_build::configure()
|
||||||
|
.build_client(false)
|
||||||
|
.build_server(true)
|
||||||
|
.out_dir(&out_dir)
|
||||||
|
.compile_protos(&im_protos, &[im_dir])?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn proto_files(proto_dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
|
||||||
|
let mut files = fs::read_dir(proto_dir)?
|
||||||
|
.map(|entry| entry.map(|entry| entry.path()))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
files.retain(|path| path.extension().is_some_and(|ext| ext == "proto"));
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
return Err(format!("no .proto files found in {}", proto_dir.display()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+61
-20
@@ -18,10 +18,10 @@ pub struct AppCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppCache {
|
impl AppCache {
|
||||||
pub fn from_config(config: &AppConfig) -> AppResult<Self> {
|
pub async fn from_config(config: &AppConfig) -> AppResult<Self> {
|
||||||
let cap = config.lru_default_capacity()?;
|
let cap = config.lru_default_capacity()?;
|
||||||
let ttl = Duration::from_secs(config.lru_default_ttl_secs()?);
|
let ttl = Duration::from_secs(config.lru_default_ttl_secs()?);
|
||||||
let l2 = AppRedis::from_config(config)?;
|
let l2 = AppRedis::from_config(config).await?;
|
||||||
let key_prefix = config.redis_key_prefix()?;
|
let key_prefix = config.redis_key_prefix()?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
l1: LruTtlCache::new(cap, ttl),
|
l1: LruTtlCache::new(cap, ttl),
|
||||||
@@ -31,17 +31,18 @@ impl AppCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
|
pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
|
||||||
if let Some(json) = self.l1.get(&key.to_string()) {
|
if let Some(json) = self.l1.get(&key.to_string()) {
|
||||||
return serde_json::from_str(&json).ok();
|
return serde_json::from_str(&json).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
let full_key = self.full_key(key);
|
let full_key = self.full_key(key);
|
||||||
let mut conn = self.l2.get_connection().ok()?;
|
let mut conn = self.l2.get_connection();
|
||||||
let json: String = Cmd::new()
|
let json: String = Cmd::new()
|
||||||
.arg("GET")
|
.arg("GET")
|
||||||
.arg(&full_key)
|
.arg(&full_key)
|
||||||
.query::<Option<String>>(&mut *conn.inner_mut())
|
.query_async::<Option<String>>(&mut conn)
|
||||||
|
.await
|
||||||
.ok()??;
|
.ok()??;
|
||||||
|
|
||||||
let value: T = serde_json::from_str(&json).ok()?;
|
let value: T = serde_json::from_str(&json).ok()?;
|
||||||
@@ -49,46 +50,86 @@ impl AppCache {
|
|||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set<T: Serialize>(&self, key: &str, value: &T, ttl: Option<Duration>) -> AppResult<()> {
|
pub async fn get_l2_only<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
|
||||||
|
let full_key = self.full_key(key);
|
||||||
|
let mut conn = self.l2.get_connection();
|
||||||
|
let json: String = Cmd::new()
|
||||||
|
.arg("GET")
|
||||||
|
.arg(&full_key)
|
||||||
|
.query_async::<Option<String>>(&mut conn)
|
||||||
|
.await
|
||||||
|
.ok()??;
|
||||||
|
|
||||||
|
serde_json::from_str(&json).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set<T: Serialize>(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: &T,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> AppResult<()> {
|
||||||
let json = serde_json::to_string(value)?;
|
let json = serde_json::to_string(value)?;
|
||||||
let full_key = self.full_key(key);
|
let full_key = self.full_key(key);
|
||||||
let ttl_duration = ttl.unwrap_or(self.default_ttl);
|
let ttl_duration = ttl.unwrap_or(self.default_ttl);
|
||||||
let ttl_secs = ttl_duration.as_secs() as usize;
|
let ttl_secs = ttl_duration.as_secs() as usize;
|
||||||
let mut conn = self.l2.get_connection()?;
|
let mut conn = self.l2.get_connection();
|
||||||
Cmd::new()
|
Cmd::new()
|
||||||
.arg("SETEX")
|
.arg("SETEX")
|
||||||
.arg(&full_key)
|
.arg(&full_key)
|
||||||
.arg(ttl_secs)
|
.arg(ttl_secs)
|
||||||
.arg(&json)
|
.arg(&json)
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
.query_async::<()>(&mut conn)
|
||||||
|
.await?;
|
||||||
self.l1.insert_with_ttl(key.to_string(), json, ttl_duration);
|
self.l1.insert_with_ttl(key.to_string(), json, ttl_duration);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(&self, key: &str) -> AppResult<()> {
|
pub async fn set_l2_only<T: Serialize>(
|
||||||
self.l1.remove(&key.to_string());
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: &T,
|
||||||
|
ttl: Option<Duration>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let json = serde_json::to_string(value)?;
|
||||||
let full_key = self.full_key(key);
|
let full_key = self.full_key(key);
|
||||||
let mut conn = self.l2.get_connection()?;
|
let ttl_duration = ttl.unwrap_or(self.default_ttl);
|
||||||
|
let ttl_secs = ttl_duration.as_secs() as usize;
|
||||||
|
let mut conn = self.l2.get_connection();
|
||||||
Cmd::new()
|
Cmd::new()
|
||||||
.arg("DEL")
|
.arg("SETEX")
|
||||||
.arg(&full_key)
|
.arg(&full_key)
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
.arg(ttl_secs)
|
||||||
|
.arg(&json)
|
||||||
|
.query_async::<()>(&mut conn)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn exists(&self, key: &str) -> bool {
|
pub async fn delete(&self, key: &str) -> AppResult<()> {
|
||||||
|
self.l1.remove(&key.to_string());
|
||||||
|
let full_key = self.full_key(key);
|
||||||
|
let mut conn = self.l2.get_connection();
|
||||||
|
Cmd::new()
|
||||||
|
.arg("DEL")
|
||||||
|
.arg(&full_key)
|
||||||
|
.query_async::<()>(&mut conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exists(&self, key: &str) -> bool {
|
||||||
if self.l1.get(&key.to_string()).is_some() {
|
if self.l1.get(&key.to_string()).is_some() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let full_key = self.full_key(key);
|
let full_key = self.full_key(key);
|
||||||
if let Ok(mut conn) = self.l2.get_connection() {
|
let mut conn = self.l2.get_connection();
|
||||||
return Cmd::new()
|
Cmd::new()
|
||||||
.arg("EXISTS")
|
.arg("EXISTS")
|
||||||
.arg(&full_key)
|
.arg(&full_key)
|
||||||
.query(&mut *conn.inner_mut())
|
.query_async::<bool>(&mut conn)
|
||||||
.unwrap_or(false);
|
.await
|
||||||
}
|
.unwrap_or(false)
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn full_key(&self, key: &str) -> String {
|
fn full_key(&self, key: &str) -> String {
|
||||||
|
|||||||
Vendored
+45
-60
@@ -1,15 +1,13 @@
|
|||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::error::AppError;
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::error::AppResult;
|
use futures_util::future::BoxFuture;
|
||||||
use r2d2::Pool;
|
|
||||||
use redis::cluster::ClusterClient;
|
use redis::cluster::ClusterClient;
|
||||||
use redis::{Client, ConnectionLike, RedisError};
|
use redis::{Client, FromRedisValue};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum RedisBackend {
|
enum RedisBackend {
|
||||||
Single(Pool<Client>),
|
Single(redis::aio::ConnectionManager),
|
||||||
Cluster(Pool<ClusterClient>),
|
Cluster(redis::cluster_async::ClusterConnection),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -18,100 +16,87 @@ pub struct AppRedis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppRedis {
|
impl AppRedis {
|
||||||
pub fn from_config(config: &AppConfig) -> AppResult<Self> {
|
pub async fn from_config(config: &AppConfig) -> AppResult<Self> {
|
||||||
let backend = if config.redis_cluster_enabled()? {
|
let backend = if config.redis_cluster_enabled()? {
|
||||||
let nodes = config.redis_cluster_nodes()?;
|
let nodes = config.redis_cluster_nodes()?;
|
||||||
let cluster_client =
|
let cluster_client =
|
||||||
ClusterClient::new(nodes.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
|
ClusterClient::new(nodes.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
|
||||||
let pool = Self::build_pool(config, cluster_client)?;
|
let conn = cluster_client.get_async_connection().await?;
|
||||||
RedisBackend::Cluster(pool)
|
RedisBackend::Cluster(conn)
|
||||||
} else {
|
} else {
|
||||||
let url = config
|
let url = config
|
||||||
.redis_url()?
|
.redis_url()?
|
||||||
.ok_or_else(|| AppError::Config("APP_REDIS_URL is not set".into()))?;
|
.ok_or_else(|| AppError::Config("APP_REDIS_URL is not set".into()))?;
|
||||||
let client = Client::open(url.as_str())?;
|
let client = Client::open(url.as_str())?;
|
||||||
let pool = Self::build_pool(config, client)?;
|
let conn = client.get_connection_manager().await?;
|
||||||
RedisBackend::Single(pool)
|
RedisBackend::Single(conn)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self { backend })
|
Ok(Self { backend })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_pool<M: r2d2::ManageConnection>(config: &AppConfig, manager: M) -> AppResult<Pool<M>> {
|
pub fn get_connection(&self) -> RedisConnection {
|
||||||
let max_conn = config.redis_max_connections()?;
|
|
||||||
let min_conn = config.redis_min_connections()?;
|
|
||||||
let idle_timeout = config.redis_idle_timeout()?;
|
|
||||||
let conn_timeout = config.redis_connection_timeout()?;
|
|
||||||
|
|
||||||
Ok(r2d2::Builder::new()
|
|
||||||
.max_size(max_conn)
|
|
||||||
.min_idle(Some(min_conn))
|
|
||||||
.idle_timeout(Some(Duration::from_secs(idle_timeout)))
|
|
||||||
.connection_timeout(Duration::from_secs(conn_timeout))
|
|
||||||
.build(manager)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_connection(&self) -> Result<PooledRedisConnection, r2d2::Error> {
|
|
||||||
match &self.backend {
|
match &self.backend {
|
||||||
RedisBackend::Single(pool) => pool.get().map(PooledRedisConnection::Single),
|
RedisBackend::Single(cm) => RedisConnection::Single(cm.clone()),
|
||||||
RedisBackend::Cluster(pool) => pool.get().map(PooledRedisConnection::Cluster),
|
RedisBackend::Cluster(cc) => RedisConnection::Cluster(cc.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
pub enum RedisConnection {
|
||||||
pub enum PooledRedisConnection {
|
Single(redis::aio::ConnectionManager),
|
||||||
Single(r2d2::PooledConnection<Client>),
|
Cluster(redis::cluster_async::ClusterConnection),
|
||||||
Cluster(r2d2::PooledConnection<ClusterClient>),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PooledRedisConnection {
|
impl redis::aio::ConnectionLike for RedisConnection {
|
||||||
pub fn inner_mut(&mut self) -> &mut dyn ConnectionLike {
|
fn req_packed_command<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
cmd: &'a redis::Cmd,
|
||||||
|
) -> BoxFuture<'a, redis::RedisResult<redis::Value>> {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn,
|
Self::Single(c) => Box::pin(c.req_packed_command(cmd)),
|
||||||
PooledRedisConnection::Cluster(conn) => conn,
|
Self::Cluster(c) => Box::pin(c.req_packed_command(cmd)),
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConnectionLike for PooledRedisConnection {
|
|
||||||
fn req_packed_command(&mut self, cmd: &[u8]) -> Result<redis::Value, RedisError> {
|
|
||||||
match self {
|
|
||||||
PooledRedisConnection::Single(conn) => conn.req_packed_command(cmd),
|
|
||||||
PooledRedisConnection::Cluster(conn) => conn.req_packed_command(cmd),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn req_packed_commands(
|
fn req_packed_commands<'a>(
|
||||||
&mut self,
|
&'a mut self,
|
||||||
cmd: &[u8],
|
cmd: &'a redis::Pipeline,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Result<Vec<redis::Value>, RedisError> {
|
) -> BoxFuture<'a, redis::RedisResult<Vec<redis::Value>>> {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn.req_packed_commands(cmd, offset, count),
|
Self::Single(c) => Box::pin(c.req_packed_commands(cmd, offset, count)),
|
||||||
PooledRedisConnection::Cluster(conn) => conn.req_packed_commands(cmd, offset, count),
|
Self::Cluster(c) => Box::pin(c.req_packed_commands(cmd, offset, count)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_db(&self) -> i64 {
|
fn get_db(&self) -> i64 {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn.get_db(),
|
Self::Single(c) => c.get_db(),
|
||||||
PooledRedisConnection::Cluster(conn) => conn.get_db(),
|
Self::Cluster(c) => c.get_db(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_connection(&mut self) -> bool {
|
impl RedisConnection {
|
||||||
|
pub async fn query_async<T: FromRedisValue>(
|
||||||
|
&mut self,
|
||||||
|
cmd: &mut redis::Cmd,
|
||||||
|
) -> redis::RedisResult<T> {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn.check_connection(),
|
Self::Single(c) => cmd.query_async(c).await,
|
||||||
PooledRedisConnection::Cluster(conn) => conn.check_connection(),
|
Self::Cluster(c) => cmd.query_async(c).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_open(&self) -> bool {
|
pub async fn query_pipeline_async<T: FromRedisValue>(
|
||||||
|
&mut self,
|
||||||
|
pipe: &mut redis::Pipeline,
|
||||||
|
) -> redis::RedisResult<T> {
|
||||||
match self {
|
match self {
|
||||||
PooledRedisConnection::Single(conn) => conn.is_open(),
|
Self::Single(c) => pipe.query_async(c).await,
|
||||||
PooledRedisConnection::Cluster(conn) => conn.is_open(),
|
Self::Cluster(c) => pipe.query_async(c).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,8 @@ impl AppConfig {
|
|||||||
pub fn rpc_default_timeout_secs(&self) -> AppResult<u64> {
|
pub fn rpc_default_timeout_secs(&self) -> AppResult<u64> {
|
||||||
self.get_env_or("APP_RPC_DEFAULT_TIMEOUT_SECS", 10)
|
self.get_env_or("APP_RPC_DEFAULT_TIMEOUT_SECS", 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn email_rpc_addr(&self) -> AppResult<Option<String>> {
|
||||||
|
self.get_env::<String>("APP_EMAIL_RPC_ADDR")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
x-appks-env: &appks-env
|
||||||
|
RUST_LOG: info
|
||||||
|
APP_HTTP_HOST: 0.0.0.0
|
||||||
|
APP_HTTP_PORT: 8000
|
||||||
|
APP_HTTP_WORKERS: 2
|
||||||
|
APP_HTTP_JSON_LIMIT_BYTES: 10485760
|
||||||
|
APP_URL: http://localhost:8000
|
||||||
|
APP_MAIN_DOMAIN: localhost
|
||||||
|
APP_SESSION_SECRET: TC5uuvxDNHLNm-BjZVhHtObtdF1oLL6bPmYlmwbNaGWe00mpWiT2uBJbeFrNYumi4UGI7sVBn83mLTIeKxfHEg
|
||||||
|
APP_SESSION_COOKIE_NAME: sid
|
||||||
|
APP_SESSION_COOKIE_SECURE: "false"
|
||||||
|
APP_SESSION_COOKIE_HTTP_ONLY: "true"
|
||||||
|
APP_SESSION_COOKIE_SAME_SITE: Lax
|
||||||
|
APP_SESSION_COOKIE_PATH: /
|
||||||
|
APP_SESSION_TTL_SECS: 86400
|
||||||
|
APP_SESSION_MAX_AGE_SECS: 86400
|
||||||
|
APP_DATABASE_URL: postgres://appks:appks@postgres:5432/appks
|
||||||
|
DATABASE_URL: postgres://appks:appks@postgres:5432/appks
|
||||||
|
APP_DATABASE_MAX_CONNECTIONS: 10
|
||||||
|
APP_DATABASE_MIN_CONNECTIONS: 2
|
||||||
|
APP_DATABASE_IDLE_TIMEOUT: 600
|
||||||
|
APP_DATABASE_MAX_LIFETIME: 3600
|
||||||
|
APP_DATABASE_CONNECTION_TIMEOUT: 8
|
||||||
|
APP_DATABASE_RETRY_ATTEMPTS: 3
|
||||||
|
APP_DATABASE_RETRY_DELAY: 5
|
||||||
|
APP_REDIS_CLUSTER_ENABLED: "true"
|
||||||
|
APP_REDIS_CLUSTER_NODES: redis://redis-node-0:6379,redis://redis-node-1:6379,redis://redis-node-2:6379,redis://redis-node-3:6379,redis://redis-node-4:6379,redis://redis-node-5:6379
|
||||||
|
APP_REDIS_READ_FROM_REPLICAS: "false"
|
||||||
|
APP_REDIS_MAX_CONNECTIONS: 20
|
||||||
|
APP_REDIS_MIN_CONNECTIONS: 2
|
||||||
|
APP_REDIS_IDLE_TIMEOUT: 300
|
||||||
|
APP_REDIS_CONNECTION_TIMEOUT: 5
|
||||||
|
APP_REDIS_MAX_RETRIES: 3
|
||||||
|
APP_REDIS_RETRY_DELAY_MS: 100
|
||||||
|
APP_REDIS_TLS_ENABLED: "false"
|
||||||
|
APP_REDIS_KEY_PREFIX: "appks:"
|
||||||
|
APP_ETCD_ENDPOINTS: http://etcd:2379
|
||||||
|
APP_ETCD_KEY_PREFIX: /appks/
|
||||||
|
APP_ETCD_CONNECT_TIMEOUT: 5
|
||||||
|
APP_ETCD_REQUEST_TIMEOUT: 10
|
||||||
|
APP_ETCD_KEEP_ALIVE_INTERVAL: 10
|
||||||
|
APP_ETCD_LEASE_TTL: 15
|
||||||
|
APP_ETCD_MAX_RETRIES: 3
|
||||||
|
APP_ETCD_REGISTER_SELF: "true"
|
||||||
|
APP_NATS_URL: nats://nats:4222
|
||||||
|
APP_NATS_CONNECTION_TIMEOUT: 5
|
||||||
|
APP_NATS_PING_INTERVAL: 20
|
||||||
|
APP_NATS_RECONNECT_DELAY: 2
|
||||||
|
APP_NATS_MAX_RECONNECTS: 60
|
||||||
|
APP_NATS_STREAM_PREFIX: APPKS
|
||||||
|
APP_NATS_ACK_WAIT_SECS: 30
|
||||||
|
APP_NATS_MAX_DELIVER: 5
|
||||||
|
APP_S3_ENDPOINT: http://minio:9000
|
||||||
|
APP_S3_REGION: us-east-1
|
||||||
|
APP_S3_ACCESS_KEY: admin
|
||||||
|
APP_S3_SECRET_KEY: mysecret123
|
||||||
|
APP_S3_BUCKET: appks
|
||||||
|
APP_S3_PATH_STYLE: "true"
|
||||||
|
APP_S3_FORCE_PATH_STYLE: "true"
|
||||||
|
APP_S3_PUBLIC_URL: http://localhost:9000/appks
|
||||||
|
APP_S3_MAX_CONNECTIONS: 50
|
||||||
|
APP_S3_IDLE_TIMEOUT: 90
|
||||||
|
APP_S3_CONNECTION_TIMEOUT: 10
|
||||||
|
APP_S3_MAX_RETRIES: 3
|
||||||
|
APP_S3_UPLOAD_PART_SIZE: 8388608
|
||||||
|
APP_S3_MAX_UPLOAD_SIZE: 104857600
|
||||||
|
APP_S3_PRESIGNED_URL_EXPIRY: 3600
|
||||||
|
APP_LRU_DEFAULT_CAPACITY: 1000
|
||||||
|
APP_LRU_DEFAULT_TTL_SECS: 300
|
||||||
|
APP_LRU_CLEANUP_INTERVAL_SECS: 60
|
||||||
|
APP_RPC_SELF_HOST: 0.0.0.0
|
||||||
|
APP_RPC_SELF_PORT: 50049
|
||||||
|
APP_RPC_SELF_REFLECTION: "false"
|
||||||
|
APP_RPC_SELF_SERVICE_NAME: appks
|
||||||
|
APP_RPC_DEFAULT_TIMEOUT_SECS: 10
|
||||||
|
|
||||||
|
services:
|
||||||
|
appks:
|
||||||
|
image: appks
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
- "50049:50049"
|
||||||
|
environment:
|
||||||
|
<<: *appks-env
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# ADR-NNN: 标题 / Title
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted** | Superseded | Deprecated
|
||||||
|
|
||||||
|
**日期 / Date**: YYYY-MM-DD
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
描述问题背景和驱动因素。
|
||||||
|
|
||||||
|
Describe the context and driving factors.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
描述做出的决策。
|
||||||
|
|
||||||
|
Describe the decision that was made.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **方案 A** — 描述 / Description
|
||||||
|
2. **方案 B** — 描述 / Description
|
||||||
|
3. **方案 C** — 描述 / Description
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 优点 1 / Advantage 1
|
||||||
|
- 优点 2 / Advantage 2
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 缺点 1 / Disadvantage 1
|
||||||
|
- 缺点 2 / Disadvantage 2
|
||||||
|
|
||||||
|
### 风险 / Risks
|
||||||
|
|
||||||
|
- 风险 1 / Risk 1
|
||||||
|
- 风险 2 / Risk 2
|
||||||
|
|
||||||
|
## 相关决策 / Related Decisions
|
||||||
|
|
||||||
|
- [ADR-XXX](xxx-related-decision.md)
|
||||||
|
|
||||||
|
## 参考 / References
|
||||||
|
|
||||||
|
- [链接 / Link](url)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ADR-001: 选择 Actix-web 作为 Web 框架 / Choice of Actix-web as Web Framework
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
appks 是一个协作开发平台后端,需要一个高性能、可靠的 Rust Web 框架来处理 HTTP 请求、WebSocket 连接和中间件。
|
||||||
|
|
||||||
|
appks is a collaborative development platform backend that needs a high-performance, reliable Rust web framework for HTTP requests, WebSocket connections, and middleware.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
选择 **Actix-web** 作为 Web 框架。
|
||||||
|
|
||||||
|
Chose **Actix-web** as the web framework.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **Actix-web** — 成熟的 actor 模型框架,性能优异
|
||||||
|
2. **Axum** — Tokio 生态新兴框架,Tower 集成
|
||||||
|
3. **Rocket** — 易用性优先的框架
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 优异的性能表现 / Excellent performance
|
||||||
|
- 成熟的生态系统 / Mature ecosystem
|
||||||
|
- 良好的 WebSocket 支持 / Good WebSocket support
|
||||||
|
- 活跃的社区维护 / Active community maintenance
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 学习曲线较陡 / Steeper learning curve
|
||||||
|
- Actor 模型需要适应 / Actor model requires adaptation
|
||||||
|
|
||||||
|
### 风险 / Risks
|
||||||
|
|
||||||
|
- 框架版本升级可能带来 breaking changes / Framework upgrades may bring breaking changes
|
||||||
|
|
||||||
|
## 参考 / References
|
||||||
|
|
||||||
|
- [Actix-web 官方文档](https://actix.rs/)
|
||||||
|
- [TechEmpower Web Framework Benchmarks](https://www.techempower.com/benchmarks/)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# ADR-002: 两级缓存架构 / Two-Tier Caching Architecture
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
平台需要缓存机制来减少数据库负载,提高响应速度。需要在性能和一致性之间取得平衡。
|
||||||
|
|
||||||
|
The platform needs a caching mechanism to reduce database load and improve response times. A balance between performance and consistency is required.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
采用 **两级缓存架构**:L1 (内存 LRU-TTL) + L2 (Redis)。
|
||||||
|
|
||||||
|
Adopted **two-tier caching**: L1 (in-memory LRU-TTL) + L2 (Redis).
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **纯 Redis** — 简单但网络延迟高
|
||||||
|
2. **纯内存缓存** — 快但不跨实例共享
|
||||||
|
3. **两级缓存** — 兼顾速度和共享
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- L1 提供极低延迟 / L1 provides ultra-low latency
|
||||||
|
- L2 提供跨实例共享 / L2 provides cross-instance sharing
|
||||||
|
- 减少数据库负载 / Reduces database load
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 缓存一致性复杂 / Cache consistency is complex
|
||||||
|
- 内存占用增加 / Increased memory usage
|
||||||
|
|
||||||
|
### 风险 / Risks
|
||||||
|
|
||||||
|
- 缓存雪崩 / Cache avalanche
|
||||||
|
- 缓存穿透 / Cache penetration
|
||||||
|
|
||||||
|
## 实现细节 / Implementation Details
|
||||||
|
|
||||||
|
- **L1**: `DashMap + Mutex<LruTracker>`, TTL 5 分钟
|
||||||
|
- **L2**: Redis via r2d2, TTL 可配置
|
||||||
|
- **策略**: L1 miss → L2 miss → 数据库查询
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# ADR-003: 使用 NATS JetStream 作为消息队列 / NATS JetStream for Messaging
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
平台的实时 IM 系统需要可靠的消息传递机制,支持发布/订阅模式和消息持久化。
|
||||||
|
|
||||||
|
The platform's real-time IM system needs reliable messaging with pub/sub patterns and message persistence.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
使用 **NATS JetStream** 作为消息队列。
|
||||||
|
|
||||||
|
Use **NATS JetStream** as the message queue.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **NATS JetStream** — 轻量级、高性能
|
||||||
|
2. **Apache Kafka** — 高吞吐但运维复杂
|
||||||
|
3. **RabbitMQ** — 功能丰富但性能较低
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 轻量级部署 / Lightweight deployment
|
||||||
|
- 高性能消息传递 / High-performance messaging
|
||||||
|
- 内置持久化 / Built-in persistence
|
||||||
|
- 良好的 Rust 客户端支持 / Good Rust client support
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 生态系统不如 Kafka 成熟 / Ecosystem less mature than Kafka
|
||||||
|
- 监控工具有限 / Limited monitoring tools
|
||||||
|
|
||||||
|
## 实现细节 / Implementation Details
|
||||||
|
|
||||||
|
- Publisher: 发布事件到 JetStream
|
||||||
|
- Subscriber: 订阅并处理事件
|
||||||
|
- Stream prefix: 可配置的流前缀
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-004: 使用 etcd 进行服务发现 / etcd for Service Discovery
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
平台依赖多个外部 gRPC 微服务(Git、Email),需要动态发现机制来连接这些服务。
|
||||||
|
|
||||||
|
The platform depends on multiple external gRPC microservices (Git, Email) and needs dynamic discovery to connect to them.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
使用 **etcd** 进行服务发现和注册。
|
||||||
|
|
||||||
|
Use **etcd** for service discovery and registration.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **etcd** — Kubernetes 生态标准,强一致性
|
||||||
|
2. **Consul** — 功能丰富但较重
|
||||||
|
3. **ZooKeeper** — 经典但运维复杂
|
||||||
|
4. **静态配置** — 简单但不灵活
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 与 Kubernetes 生态一致 / Aligned with Kubernetes ecosystem
|
||||||
|
- 强一致性保证 / Strong consistency guarantees
|
||||||
|
- 租约机制支持健康检查 / Lease mechanism supports health checks
|
||||||
|
- Watch 机制支持实时更新 / Watch mechanism supports real-time updates
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 额外的基础设施依赖 / Additional infrastructure dependency
|
||||||
|
- 运维复杂度增加 / Increased operational complexity
|
||||||
|
|
||||||
|
## 实现细节 / Implementation Details
|
||||||
|
|
||||||
|
- **注册**: `register.rs` — 自注册 + 租约保活
|
||||||
|
- **发现**: `discovery.rs` — Watch 动态连接
|
||||||
|
- **客户端**: tonic/prost gRPC 客户端
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-005: 统一错误处理策略 / Unified Error Handling Strategy
|
||||||
|
|
||||||
|
## 状态 / Status
|
||||||
|
|
||||||
|
**Accepted**
|
||||||
|
|
||||||
|
**日期 / Date**: 2024-01-01
|
||||||
|
|
||||||
|
## 背景 / Context
|
||||||
|
|
||||||
|
平台需要统一的错误处理机制,确保错误信息对用户友好,同时保留足够的调试信息。
|
||||||
|
|
||||||
|
The platform needs a unified error handling mechanism that provides user-friendly error messages while retaining sufficient debugging information.
|
||||||
|
|
||||||
|
## 决策 / Decision
|
||||||
|
|
||||||
|
使用 **AppError 枚举 + AppResult 类型别名** 作为统一错误处理策略。
|
||||||
|
|
||||||
|
Use **AppError enum + AppResult type alias** as the unified error handling strategy.
|
||||||
|
|
||||||
|
## 考虑的方案 / Considered Options
|
||||||
|
|
||||||
|
1. **自定义枚举 (AppError)** — 类型安全、可扩展
|
||||||
|
2. **anyhow** — 简单但类型信息丢失
|
||||||
|
3. **thiserror + 手动实现** — 灵活但工作量大
|
||||||
|
|
||||||
|
## 后果 / Consequences
|
||||||
|
|
||||||
|
### 正面 / Positive
|
||||||
|
|
||||||
|
- 类型安全的错误处理 / Type-safe error handling
|
||||||
|
- 统一的错误响应格式 / Unified error response format
|
||||||
|
- 便于错误分类和监控 / Easy error classification and monitoring
|
||||||
|
- 与 actix-web 集成良好 / Good integration with actix-web
|
||||||
|
|
||||||
|
### 负面 / Negative
|
||||||
|
|
||||||
|
- 需要维护错误枚举 / Need to maintain error enum
|
||||||
|
- 新增错误类型需要更新枚举 / New error types require enum updates
|
||||||
|
|
||||||
|
## 实现细节 / Implementation Details
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// AppError 定义
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("User not found")]
|
||||||
|
UserNotFound,
|
||||||
|
#[error("Invalid password")]
|
||||||
|
InvalidPassword,
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppResult 类型别名
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误码映射 / Error Code Mapping
|
||||||
|
|
||||||
|
| Postgres Code | 含义 | HTTP Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 23505 | 唯一约束违反 | 409 Conflict |
|
||||||
|
| 23503 | 外键约束违反 | 400 Bad Request |
|
||||||
|
| 23514 | 检查约束违反 | 400 Bad Request |
|
||||||
|
| 23502 | 非空约束违反 | 400 Bad Request |
|
||||||
|
| 23P01 | 排他约束违反 | 409 Conflict |
|
||||||
@@ -14,9 +14,6 @@ pub enum AppError {
|
|||||||
#[error("redis error: {0}")]
|
#[error("redis error: {0}")]
|
||||||
Redis(#[from] redis::RedisError),
|
Redis(#[from] redis::RedisError),
|
||||||
|
|
||||||
#[error("r2d2 error: {0}")]
|
|
||||||
R2d2(#[from] r2d2::Error),
|
|
||||||
|
|
||||||
#[error("json error: {0}")]
|
#[error("json error: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
@@ -131,6 +128,7 @@ impl actix_web::ResponseError for AppError {
|
|||||||
| AppError::InvalidEmailCode
|
| AppError::InvalidEmailCode
|
||||||
| AppError::RsaDecodeError
|
| AppError::RsaDecodeError
|
||||||
| AppError::RsaGenerationError => StatusCode::BAD_REQUEST,
|
| AppError::RsaGenerationError => StatusCode::BAD_REQUEST,
|
||||||
|
AppError::Database(e) => db_error_status_code(e),
|
||||||
AppError::PasswordHashError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
AppError::PasswordHashError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
@@ -139,6 +137,7 @@ impl actix_web::ResponseError for AppError {
|
|||||||
fn error_response(&self) -> HttpResponse {
|
fn error_response(&self) -> HttpResponse {
|
||||||
let status = self.status_code();
|
let status = self.status_code();
|
||||||
let message = if status == actix_web::http::StatusCode::INTERNAL_SERVER_ERROR {
|
let message = if status == actix_web::http::StatusCode::INTERNAL_SERVER_ERROR {
|
||||||
|
tracing::error!(?self, "internal server error");
|
||||||
"internal server error".to_string()
|
"internal server error".to_string()
|
||||||
} else {
|
} else {
|
||||||
self.to_string()
|
self.to_string()
|
||||||
@@ -146,3 +145,25 @@ impl actix_web::ResponseError for AppError {
|
|||||||
HttpResponse::build(status).json(serde_json::json!({ "error": message }))
|
HttpResponse::build(status).json(serde_json::json!({ "error": message }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn db_error_status_code(e: &sqlx::Error) -> actix_web::http::StatusCode {
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
match e {
|
||||||
|
sqlx::Error::Database(db_err) => {
|
||||||
|
match db_err.code().as_ref().map(|c| c.as_ref()) {
|
||||||
|
// unique_violation
|
||||||
|
Some("23505") => StatusCode::CONFLICT,
|
||||||
|
// foreign_key_violation
|
||||||
|
Some("23503") => StatusCode::CONFLICT,
|
||||||
|
// check_violation
|
||||||
|
Some("23514") => StatusCode::BAD_REQUEST,
|
||||||
|
// not_null_violation
|
||||||
|
Some("23502") => StatusCode::BAD_REQUEST,
|
||||||
|
// exclusion_violation
|
||||||
|
Some("23P01") => StatusCode::CONFLICT,
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+148
-22
@@ -5,18 +5,152 @@ use uuid::Uuid;
|
|||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::pb::{EmailClient, RepoClient};
|
use crate::pb::{EmailClient, RepoClient};
|
||||||
|
|
||||||
use super::types::ServiceInstance;
|
use super::types::{GitksPeerInfo, ServiceInstance};
|
||||||
use super::{EtcdRegistry, EtcdRegistryInner};
|
use super::{EtcdRegistry, EtcdRegistryInner, storage_name_to_uuid};
|
||||||
|
|
||||||
|
/// etcd prefix where gitks nodes register themselves (gitks::cluster::ClusterManager).
|
||||||
|
const GITKS_NODES_PREFIX: &str = "/gitks/nodes/";
|
||||||
|
|
||||||
impl EtcdRegistry {
|
impl EtcdRegistry {
|
||||||
pub async fn start_discovery(&self) -> AppResult<()> {
|
pub async fn start_discovery(&self) -> AppResult<()> {
|
||||||
self.load_initial("git").await?;
|
// Discover gitks nodes from gitks's own etcd prefix.
|
||||||
|
self.load_gitks_nodes().await?;
|
||||||
|
self.spawn_gitks_watch();
|
||||||
|
|
||||||
|
// Discover mail services from appks's service prefix.
|
||||||
|
if self.email_client.is_none() {
|
||||||
self.load_initial("mail").await?;
|
self.load_initial("mail").await?;
|
||||||
self.spawn_watch("git");
|
|
||||||
self.spawn_watch("mail");
|
self.spawn_watch("mail");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section: gitks node discovery (from /gitks/nodes/)
|
||||||
|
|
||||||
|
async fn load_gitks_nodes(&self) -> AppResult<()> {
|
||||||
|
let resp = {
|
||||||
|
let mut client = self.inner.client.lock().await;
|
||||||
|
client
|
||||||
|
.get(GITKS_NODES_PREFIX, Some(GetOptions::new().with_prefix()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::Config(format!("etcd get {GITKS_NODES_PREFIX} failed: {e}"))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
for kv in resp.kvs() {
|
||||||
|
let key = kv.key_str().unwrap_or_default();
|
||||||
|
let value = kv.value_str().unwrap_or_default();
|
||||||
|
if let Ok(peer) = serde_json::from_str::<GitksPeerInfo>(value) {
|
||||||
|
Self::upsert_gitks_node(&self.inner, key, &peer);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(key = key, "failed to parse gitks peer info from etcd");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
prefix = GITKS_NODES_PREFIX,
|
||||||
|
count = self.inner.git_nodes.len(),
|
||||||
|
"gitks node discovery complete"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_gitks_watch(&self) {
|
||||||
|
let inner = self.inner.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match Self::gitks_watch_loop(&inner).await {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "gitks etcd watch disconnected, retrying in 3s");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gitks_watch_loop(inner: &EtcdRegistryInner) -> AppResult<()> {
|
||||||
|
let mut stream = {
|
||||||
|
let mut client = inner.client.lock().await;
|
||||||
|
client
|
||||||
|
.watch(GITKS_NODES_PREFIX, Some(WatchOptions::new().with_prefix()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::Config(format!("etcd watch {GITKS_NODES_PREFIX} failed: {e}"))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Some(resp) = stream.next().await {
|
||||||
|
let resp =
|
||||||
|
resp.map_err(|e| AppError::Config(format!("gitks watch stream error: {e}")))?;
|
||||||
|
|
||||||
|
for event in resp.events() {
|
||||||
|
let Some(kv) = event.kv() else { continue };
|
||||||
|
let key = kv.key_str().unwrap_or_default();
|
||||||
|
|
||||||
|
match event.event_type() {
|
||||||
|
etcd_client::EventType::Put => {
|
||||||
|
let value = kv.value_str().unwrap_or_default();
|
||||||
|
if let Ok(peer) = serde_json::from_str::<GitksPeerInfo>(value) {
|
||||||
|
Self::upsert_gitks_node(inner, key, &peer);
|
||||||
|
tracing::info!(
|
||||||
|
storage_name = %peer.storage_name,
|
||||||
|
grpc_addr = %peer.grpc_addr,
|
||||||
|
"gitks node upserted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
etcd_client::EventType::Delete => {
|
||||||
|
let storage_name = key.strip_prefix(GITKS_NODES_PREFIX).unwrap_or(&key);
|
||||||
|
let node_id = storage_name_to_uuid(storage_name);
|
||||||
|
inner.git_nodes.remove(&node_id);
|
||||||
|
tracing::info!(storage_name = storage_name, "gitks node removed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_gitks_node(inner: &EtcdRegistryInner, key: &str, peer: &GitksPeerInfo) {
|
||||||
|
let node_id = storage_name_to_uuid(&peer.storage_name);
|
||||||
|
|
||||||
|
if peer.grpc_addr.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
storage_name = %peer.storage_name,
|
||||||
|
key = key,
|
||||||
|
"gitks peer has empty grpc_addr, skipping"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match RepoClient::lazy_connect(&peer.grpc_addr) {
|
||||||
|
Ok(client) => {
|
||||||
|
inner.git_nodes.insert(node_id, client);
|
||||||
|
tracing::debug!(
|
||||||
|
storage_name = %peer.storage_name,
|
||||||
|
node_id = %node_id,
|
||||||
|
grpc_addr = %peer.grpc_addr,
|
||||||
|
"gitks node connected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
storage_name = %peer.storage_name,
|
||||||
|
grpc_addr = %peer.grpc_addr,
|
||||||
|
error = %e,
|
||||||
|
"gitks node connect failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section: mail service discovery (from appks's own etcd prefix)
|
||||||
|
|
||||||
async fn load_initial(&self, service: &str) -> AppResult<()> {
|
async fn load_initial(&self, service: &str) -> AppResult<()> {
|
||||||
let prefix = self.service_prefix(service);
|
let prefix = self.service_prefix(service);
|
||||||
let resp = {
|
let resp = {
|
||||||
@@ -38,7 +172,7 @@ impl EtcdRegistry {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
service = service,
|
service = service,
|
||||||
prefix = prefix.as_str(),
|
prefix = prefix.as_str(),
|
||||||
"etcd initial discovery complete"
|
"etcd mail discovery complete"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -66,7 +200,7 @@ impl EtcdRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> {
|
async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> {
|
||||||
let (mut watcher, mut stream) = {
|
let mut stream = {
|
||||||
let mut client = inner.client.lock().await;
|
let mut client = inner.client.lock().await;
|
||||||
client
|
client
|
||||||
.watch(prefix, Some(WatchOptions::new().with_prefix()))
|
.watch(prefix, Some(WatchOptions::new().with_prefix()))
|
||||||
@@ -74,8 +208,6 @@ impl EtcdRegistry {
|
|||||||
.map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))?
|
.map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let _keep = &mut watcher;
|
|
||||||
|
|
||||||
while let Some(resp) = stream.next().await {
|
while let Some(resp) = stream.next().await {
|
||||||
let resp =
|
let resp =
|
||||||
resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?;
|
resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?;
|
||||||
@@ -89,12 +221,12 @@ impl EtcdRegistry {
|
|||||||
let value = kv.value_str().unwrap_or_default();
|
let value = kv.value_str().unwrap_or_default();
|
||||||
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
|
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
|
||||||
Self::upsert_instance(inner, service, key, &instance);
|
Self::upsert_instance(inner, service, key, &instance);
|
||||||
tracing::info!(service = service, key = key, "etcd service upserted");
|
tracing::info!(service = service, key = key, "mail service upserted");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
etcd_client::EventType::Delete => {
|
etcd_client::EventType::Delete => {
|
||||||
Self::remove_instance(inner, service, key);
|
Self::remove_instance(inner, service, key);
|
||||||
tracing::info!(service = service, key = key, "etcd service removed");
|
tracing::info!(service = service, key = key, "mail service removed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,20 +255,17 @@ impl EtcdRegistry {
|
|||||||
let addr = instance.addr.clone();
|
let addr = instance.addr.clone();
|
||||||
|
|
||||||
match service {
|
match service {
|
||||||
"git" => match RepoClient::lazy_connect(&addr) {
|
|
||||||
Ok(client) => {
|
|
||||||
inner.git_nodes.insert(node_id, client);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(key = key, addr = addr.as_str(), error = %e, "git client connect failed");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mail" => match EmailClient::lazy_connect(&addr) {
|
"mail" => match EmailClient::lazy_connect(&addr) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
inner.mail_nodes.insert(node_id, client);
|
inner.mail_nodes.insert(node_id, client);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(key = key, addr = addr.as_str(), error = %e, "mail client connect failed");
|
tracing::error!(
|
||||||
|
key = key,
|
||||||
|
addr = addr.as_str(),
|
||||||
|
error = %e,
|
||||||
|
"mail client connect failed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -148,9 +277,6 @@ impl EtcdRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match service {
|
match service {
|
||||||
"git" => {
|
|
||||||
inner.git_nodes.remove(&node_id);
|
|
||||||
}
|
|
||||||
"mail" => {
|
"mail" => {
|
||||||
inner.mail_nodes.remove(&node_id);
|
inner.mail_nodes.remove(&node_id);
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-2
@@ -2,7 +2,7 @@ mod discovery;
|
|||||||
mod register;
|
mod register;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use types::ServiceInstance;
|
pub use types::{GitksPeerInfo, ServiceInstance};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicI64;
|
use std::sync::atomic::AtomicI64;
|
||||||
@@ -19,6 +19,7 @@ use crate::pb::{EmailClient, RepoClient};
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct EtcdRegistry {
|
pub struct EtcdRegistry {
|
||||||
pub(crate) inner: Arc<EtcdRegistryInner>,
|
pub(crate) inner: Arc<EtcdRegistryInner>,
|
||||||
|
email_client: Option<EmailClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct EtcdRegistryInner {
|
pub(crate) struct EtcdRegistryInner {
|
||||||
@@ -38,7 +39,7 @@ impl EtcdRegistry {
|
|||||||
let opts = etcd_client::ConnectOptions::new()
|
let opts = etcd_client::ConnectOptions::new()
|
||||||
.with_connect_timeout(std::time::Duration::from_secs(timeout));
|
.with_connect_timeout(std::time::Duration::from_secs(timeout));
|
||||||
|
|
||||||
let client = Client::connect(&endpoints, Some(opts))
|
let client = Client::connect(endpoints, Some(opts))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?;
|
.map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?;
|
||||||
|
|
||||||
@@ -54,6 +55,25 @@ impl EtcdRegistry {
|
|||||||
|
|
||||||
let key_prefix = config.etcd_key_prefix()?;
|
let key_prefix = config.etcd_key_prefix()?;
|
||||||
|
|
||||||
|
let email_client = match config.email_rpc_addr()? {
|
||||||
|
Some(addr) if !addr.is_empty() => match EmailClient::lazy_connect(&addr) {
|
||||||
|
Ok(client) => {
|
||||||
|
tracing::info!(addr = %addr, "email client connected via APP_EMAIL_RPC_ADDR");
|
||||||
|
Some(client)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(addr = %addr, error = %e, "email client connect via APP_EMAIL_RPC_ADDR failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
tracing::info!(
|
||||||
|
"APP_EMAIL_RPC_ADDR not set, will fall back to etcd discovery for email"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: Arc::new(EtcdRegistryInner {
|
inner: Arc::new(EtcdRegistryInner {
|
||||||
client: Mutex::new(client),
|
client: Mutex::new(client),
|
||||||
@@ -63,6 +83,7 @@ impl EtcdRegistry {
|
|||||||
mail_nodes: DashMap::new(),
|
mail_nodes: DashMap::new(),
|
||||||
lease_id: AtomicI64::new(0),
|
lease_id: AtomicI64::new(0),
|
||||||
}),
|
}),
|
||||||
|
email_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +96,9 @@ impl EtcdRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_email_client(&self) -> Option<EmailClient> {
|
pub fn get_email_client(&self) -> Option<EmailClient> {
|
||||||
|
if let Some(ref client) = self.email_client {
|
||||||
|
return Some(client.clone());
|
||||||
|
}
|
||||||
self.inner
|
self.inner
|
||||||
.mail_nodes
|
.mail_nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -85,4 +109,44 @@ impl EtcdRegistry {
|
|||||||
pub fn has_git_nodes(&self) -> bool {
|
pub fn has_git_nodes(&self) -> bool {
|
||||||
!self.inner.git_nodes.is_empty()
|
!self.inner.git_nodes.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sort available gitks node UUIDs for deterministic selection.
|
||||||
|
pub fn git_node_ids_sorted(&self) -> Vec<Uuid> {
|
||||||
|
let mut ids: Vec<Uuid> = self.git_node_ids();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read config from etcd. Priority: etcd > env > default.
|
||||||
|
/// This is async but can be called from sync context via block_on.
|
||||||
|
pub async fn get_config(&self, key: &str, default: &str) -> String {
|
||||||
|
let etcd_key = format!("{}config/{}", self.inner.key_prefix, key);
|
||||||
|
let mut client = self.inner.client.lock().await;
|
||||||
|
if let Ok(resp) = client.get(etcd_key.as_str(), None).await {
|
||||||
|
if let Some(kv) = resp.kvs().first() {
|
||||||
|
if let Ok(v) = kv.value_str() {
|
||||||
|
if !v.is_empty() {
|
||||||
|
tracing::info!(key, value = v, "config from etcd");
|
||||||
|
return v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(client);
|
||||||
|
// Fall back to env
|
||||||
|
if let Ok(v) = std::env::var(key) {
|
||||||
|
if !v.is_empty() {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic UUID from a gitks storage_name.
|
||||||
|
/// Uses UUID v5 with DNS namespace so the same storage_name always
|
||||||
|
/// maps to the same UUID across all appks instances.
|
||||||
|
pub fn storage_name_to_uuid(storage_name: &str) -> Uuid {
|
||||||
|
Uuid::new_v5(&Uuid::NAMESPACE_DNS, storage_name.as_bytes())
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-66
@@ -2,12 +2,9 @@ use std::collections::HashMap;
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use etcd_client::PutOptions;
|
use etcd_client::PutOptions;
|
||||||
use tokio_stream::StreamExt;
|
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
use super::EtcdRegistry;
|
use super::EtcdRegistry;
|
||||||
use super::EtcdRegistryInner;
|
|
||||||
use super::types::ServiceInstance;
|
use super::types::ServiceInstance;
|
||||||
|
|
||||||
impl EtcdRegistry {
|
impl EtcdRegistry {
|
||||||
@@ -58,74 +55,30 @@ impl EtcdRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_keep_alive(&self, lease_id: i64, key: String) {
|
fn spawn_keep_alive(&self, lease_id: i64, _key: String) {
|
||||||
let inner = self.inner.clone();
|
let inner = self.inner.clone();
|
||||||
let interval = self.inner.config.etcd_keep_alive_interval().unwrap_or(10);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let (mut keeper, mut stream) = {
|
||||||
|
let mut client = inner.client.lock().await;
|
||||||
|
match client.lease_keep_alive(lease_id).await {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(lease_id, error = %e, "failed to start lease keepalive");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let interval_secs = inner.config.etcd_keep_alive_interval().unwrap_or(10);
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
|
||||||
loop {
|
loop {
|
||||||
Self::run_keep_alive_stream(&inner, lease_id).await;
|
interval.tick().await;
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
|
if let Err(e) = keeper.keep_alive().await {
|
||||||
Self::renew_lease_and_reregister(&inner, lease_id, &key).await;
|
tracing::warn!(lease_id, error = %e, "lease keepalive failed");
|
||||||
|
}
|
||||||
|
let _ = stream.message().await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EtcdRegistry {
|
|
||||||
async fn run_keep_alive_stream(
|
|
||||||
inner: &std::sync::Arc<EtcdRegistryInner>,
|
|
||||||
lease_id: i64,
|
|
||||||
) {
|
|
||||||
let result = {
|
|
||||||
let mut client = inner.client.lock().await;
|
|
||||||
client.lease_keep_alive(lease_id).await
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok((_keeper, mut stream)) => {
|
|
||||||
while let Some(resp) = stream.next().await {
|
|
||||||
if let Err(e) = resp {
|
|
||||||
tracing::warn!(lease_id = lease_id, error = %e, "keep-alive stream error");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(lease_id = lease_id, error = %e, "keep-alive failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn renew_lease_and_reregister(
|
|
||||||
inner: &std::sync::Arc<EtcdRegistryInner>,
|
|
||||||
old_lease_id: i64,
|
|
||||||
key: &str,
|
|
||||||
) {
|
|
||||||
let re_grant = {
|
|
||||||
let mut client = inner.client.lock().await;
|
|
||||||
client
|
|
||||||
.lease_grant(inner.config.etcd_lease_ttl().unwrap_or(15) as i64, None)
|
|
||||||
.await
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(current) = re_grant else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_lease = current.id();
|
|
||||||
inner.lease_id.store(new_lease, Ordering::SeqCst);
|
|
||||||
|
|
||||||
let instance = ServiceInstance {
|
|
||||||
addr: inner.config.rpc_self_listen_addr().unwrap_or_default(),
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(value) = serde_json::to_string(&instance) {
|
|
||||||
let mut client = inner.client.lock().await;
|
|
||||||
let opts = PutOptions::new().with_lease(new_lease);
|
|
||||||
let _ = client.put(key, value, Some(opts)).await;
|
|
||||||
}
|
|
||||||
tracing::info!(old = old_lease_id, new = new_lease, "etcd lease renewed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,3 +8,19 @@ pub struct ServiceInstance {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metadata: HashMap<String, String>,
|
pub metadata: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Information about a gitks peer node, registered in etcd under /gitks/nodes/.
|
||||||
|
/// Mirrors gitks::cluster::types::PeerInfo.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GitksPeerInfo {
|
||||||
|
/// Logical storage name (e.g. "node-a", "default")
|
||||||
|
pub storage_name: String,
|
||||||
|
/// ractor_cluster TCP address (e.g. "10.0.1.4:4697")
|
||||||
|
#[serde(default)]
|
||||||
|
pub cluster_addr: String,
|
||||||
|
/// gRPC service address (e.g. "http://10.0.1.4:50051")
|
||||||
|
pub grpc_addr: String,
|
||||||
|
/// Software version
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -2,10 +2,13 @@ use appks::api::openapi::OpenApiDoc;
|
|||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
println!("Generating OpenAPI documentation...");
|
||||||
let json = OpenApiDoc::openapi().to_pretty_json();
|
let json = OpenApiDoc::openapi().to_pretty_json();
|
||||||
if let Ok(json) = json {
|
if let Ok(json) = json {
|
||||||
if let Err(e) = std::fs::write("openapi.json", json) {
|
if let Err(e) = std::fs::write("openapi.json", json) {
|
||||||
eprintln!("{}", e);
|
println!("Failed to write OpenAPI documentation. {}", e);
|
||||||
|
} else {
|
||||||
|
println!("OpenAPI documentation generated successfully.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
|
||||||
|
use crate::pb::core::token_service_server::TokenService as TokenServiceTrait;
|
||||||
|
use crate::pb::core::{
|
||||||
|
GetSigningKeysRequest, GetSigningKeysResponse, IssueTokenRequest, IssueTokenResponse,
|
||||||
|
RefreshTokenRequest, RefreshTokenResponse, RevokeTokenRequest, RevokeTokenResponse,
|
||||||
|
SigningKey, TokenClaims as PbTokenClaims, VerifyTokenRequest, VerifyTokenResponse,
|
||||||
|
revoke_token_request::Target,
|
||||||
|
};
|
||||||
|
use crate::service::internal_auth::TokenService;
|
||||||
|
|
||||||
|
pub struct TokenGrpcService {
|
||||||
|
service: TokenService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenGrpcService {
|
||||||
|
pub fn new(service: TokenService) -> Self {
|
||||||
|
Self { service }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl TokenServiceTrait for TokenGrpcService {
|
||||||
|
async fn issue_token(
|
||||||
|
&self,
|
||||||
|
request: Request<IssueTokenRequest>,
|
||||||
|
) -> Result<Response<IssueTokenResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
if req.user_id.is_empty() {
|
||||||
|
return Err(Status::invalid_argument("user_id is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ttl = if req.ttl_secs > 0 { req.ttl_secs } else { 3600 };
|
||||||
|
|
||||||
|
let tokens = self
|
||||||
|
.service
|
||||||
|
.issue_token(
|
||||||
|
&req.user_id,
|
||||||
|
ttl,
|
||||||
|
req.scopes,
|
||||||
|
req.extra,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(IssueTokenResponse {
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
expires_at: tokens.expires_at,
|
||||||
|
key_id: tokens.key_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_token(
|
||||||
|
&self,
|
||||||
|
request: Request<RefreshTokenRequest>,
|
||||||
|
) -> Result<Response<RefreshTokenResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
let tokens = self
|
||||||
|
.service
|
||||||
|
.refresh_token(&req.refresh_token, 3600)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::unauthenticated(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(RefreshTokenResponse {
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
expires_at: tokens.expires_at,
|
||||||
|
key_id: tokens.key_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_token(
|
||||||
|
&self,
|
||||||
|
request: Request<RevokeTokenRequest>,
|
||||||
|
) -> Result<Response<RevokeTokenResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
match req.target {
|
||||||
|
Some(Target::Jti(jti)) => {
|
||||||
|
self.service
|
||||||
|
.revoke_by_jti(&jti, 86400)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
Ok(Response::new(RevokeTokenResponse { revoked_count: 1 }))
|
||||||
|
}
|
||||||
|
Some(Target::UserId(user_id)) => {
|
||||||
|
let count = self
|
||||||
|
.service
|
||||||
|
.revoke_user_tokens(&user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
Ok(Response::new(RevokeTokenResponse {
|
||||||
|
revoked_count: count as i32,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Err(Status::invalid_argument("target is required")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_token(
|
||||||
|
&self,
|
||||||
|
request: Request<VerifyTokenRequest>,
|
||||||
|
) -> Result<Response<VerifyTokenResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
match self
|
||||||
|
.service
|
||||||
|
.verify_token(&req.token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
{
|
||||||
|
Ok(claims) => Ok(Response::new(VerifyTokenResponse {
|
||||||
|
valid: true,
|
||||||
|
claims: Some(PbTokenClaims {
|
||||||
|
sub: claims.sub,
|
||||||
|
iss: claims.iss,
|
||||||
|
iat: claims.iat,
|
||||||
|
exp: claims.exp,
|
||||||
|
jti: claims.jti,
|
||||||
|
scope: claims.scope,
|
||||||
|
extra: claims.extra,
|
||||||
|
}),
|
||||||
|
reason: String::new(),
|
||||||
|
})),
|
||||||
|
Err(reason) => Ok(Response::new(VerifyTokenResponse {
|
||||||
|
valid: false,
|
||||||
|
claims: None,
|
||||||
|
reason,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_signing_keys(
|
||||||
|
&self,
|
||||||
|
_request: Request<GetSigningKeysRequest>,
|
||||||
|
) -> Result<Response<GetSigningKeysResponse>, Status> {
|
||||||
|
let (keys, next_rotation_at) = self
|
||||||
|
.service
|
||||||
|
.get_signing_keys()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(GetSigningKeysResponse {
|
||||||
|
keys: keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|k| SigningKey {
|
||||||
|
kid: k.kid,
|
||||||
|
algorithm: k.algorithm,
|
||||||
|
key_material: k.key_material,
|
||||||
|
issued_at: k.issued_at,
|
||||||
|
expires_at: k.expires_at,
|
||||||
|
active: k.active,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
next_rotation_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
+443
@@ -0,0 +1,443 @@
|
|||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::channels::ChannelStats;
|
||||||
|
use crate::models::common::{ChannelKind, ChannelType, Visibility};
|
||||||
|
use crate::models::workspaces::Workspace;
|
||||||
|
use crate::pb::im::channel_service_server::ChannelService;
|
||||||
|
use crate::pb::im::{
|
||||||
|
CreateCategoryRequest, CreateCategoryResponse, CreateChannelRequest, CreateChannelResponse,
|
||||||
|
DeleteCategoryRequest, DeleteCategoryResponse, DeleteChannelRequest, DeleteChannelResponse,
|
||||||
|
GetChannelRequest, GetChannelResponse, GetChannelStatsRequest, GetChannelStatsResponse,
|
||||||
|
ListCategoriesRequest, ListCategoriesResponse, ListChannelsRequest, ListChannelsResponse,
|
||||||
|
UpdateCategoryRequest, UpdateCategoryResponse, UpdateChannelRequest, UpdateChannelResponse,
|
||||||
|
};
|
||||||
|
use crate::service::im::categories::{CreateCategoryParams, UpdateCategoryParams};
|
||||||
|
use crate::service::im::channels::{ChannelListFilters, CreateChannelParams, UpdateChannelParams};
|
||||||
|
use crate::service::im::session::ImSession;
|
||||||
|
use crate::service::AppService;
|
||||||
|
|
||||||
|
pub struct ChannelGrpcService {
|
||||||
|
service: AppService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelGrpcService {
|
||||||
|
pub fn new(service: AppService) -> Self {
|
||||||
|
Self { service }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_session() -> ImSession {
|
||||||
|
ImSession::new(Uuid::nil())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
|
||||||
|
Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_workspace_name(&self, workspace_id: Uuid) -> Result<String, Status> {
|
||||||
|
Workspace::find_by_id(self.service.ctx.db.reader(), workspace_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
.map(|w| w.name)
|
||||||
|
.ok_or_else(|| Status::not_found("workspace not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_proto_timestamp(dt: chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
|
||||||
|
prost_types::Timestamp {
|
||||||
|
seconds: dt.timestamp(),
|
||||||
|
nanos: dt.timestamp_subsec_nanos() as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_channel_to_proto(c: crate::models::channels::Channel) -> crate::pb::im::Channel {
|
||||||
|
let channel_type = match c.channel_type {
|
||||||
|
ChannelType::Public => crate::pb::im::ChannelType::Public,
|
||||||
|
ChannelType::Private => crate::pb::im::ChannelType::Private,
|
||||||
|
ChannelType::Direct => crate::pb::im::ChannelType::Direct,
|
||||||
|
ChannelType::Group => crate::pb::im::ChannelType::Group,
|
||||||
|
ChannelType::Repo => crate::pb::im::ChannelType::Repo,
|
||||||
|
ChannelType::System => crate::pb::im::ChannelType::System,
|
||||||
|
ChannelType::Unknown => crate::pb::im::ChannelType::Unspecified,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel_kind = match c.channel_kind {
|
||||||
|
ChannelKind::Text => crate::pb::im::ChannelKind::Text,
|
||||||
|
ChannelKind::Voice => crate::pb::im::ChannelKind::Voice,
|
||||||
|
ChannelKind::Stage => crate::pb::im::ChannelKind::Stage,
|
||||||
|
ChannelKind::Forum => crate::pb::im::ChannelKind::Forum,
|
||||||
|
ChannelKind::Announcement => crate::pb::im::ChannelKind::Announcement,
|
||||||
|
ChannelKind::Unknown => crate::pb::im::ChannelKind::Unspecified,
|
||||||
|
};
|
||||||
|
|
||||||
|
let visibility = match c.visibility {
|
||||||
|
Visibility::Public => crate::pb::im::Visibility::Public,
|
||||||
|
Visibility::Private => crate::pb::im::Visibility::Private,
|
||||||
|
Visibility::Internal => crate::pb::im::Visibility::Internal,
|
||||||
|
Visibility::Workspace => crate::pb::im::Visibility::Workspace,
|
||||||
|
Visibility::Protected => crate::pb::im::Visibility::Protected,
|
||||||
|
Visibility::Hidden => crate::pb::im::Visibility::Hidden,
|
||||||
|
Visibility::Secret => crate::pb::im::Visibility::Secret,
|
||||||
|
Visibility::Unknown => crate::pb::im::Visibility::Unspecified,
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::pb::im::Channel {
|
||||||
|
id: c.id.to_string(),
|
||||||
|
workspace_id: c.workspace_id.to_string(),
|
||||||
|
category_id: c.category_id.map(|id| id.to_string()),
|
||||||
|
parent_channel_id: c.parent_channel_id.map(|id| id.to_string()),
|
||||||
|
name: c.name,
|
||||||
|
topic: c.topic,
|
||||||
|
description: c.description,
|
||||||
|
channel_type: channel_type.into(),
|
||||||
|
channel_kind: channel_kind.into(),
|
||||||
|
visibility: visibility.into(),
|
||||||
|
position: c.position.unwrap_or(0),
|
||||||
|
nsfw: c.nsfw,
|
||||||
|
read_only: c.read_only,
|
||||||
|
archived: c.archived,
|
||||||
|
created_by: Some(c.created_by.to_string()),
|
||||||
|
rate_limit_per_user: c.rate_limit_per_user,
|
||||||
|
archived_at: c.archived_at.map(Self::to_proto_timestamp),
|
||||||
|
last_message_id: c.last_message_id.map(|id| id.to_string()),
|
||||||
|
last_message_at: c.last_message_at.map(Self::to_proto_timestamp),
|
||||||
|
created_at: Some(Self::to_proto_timestamp(c.created_at)),
|
||||||
|
updated_at: Some(Self::to_proto_timestamp(c.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_category_to_proto(
|
||||||
|
c: crate::models::channels::ChannelCategory,
|
||||||
|
) -> crate::pb::im::ChannelCategory {
|
||||||
|
crate::pb::im::ChannelCategory {
|
||||||
|
id: c.id.to_string(),
|
||||||
|
workspace_id: c.workspace_id.to_string(),
|
||||||
|
name: c.name,
|
||||||
|
position: c.position,
|
||||||
|
collapsed: c.collapsed,
|
||||||
|
created_at: Some(Self::to_proto_timestamp(c.created_at)),
|
||||||
|
updated_at: Some(Self::to_proto_timestamp(c.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_stats_to_proto(s: ChannelStats) -> crate::pb::im::ChannelStats {
|
||||||
|
crate::pb::im::ChannelStats {
|
||||||
|
channel_id: s.channel_id.to_string(),
|
||||||
|
members_count: s.members_count as i32,
|
||||||
|
messages_count: s.messages_count as i32,
|
||||||
|
threads_count: s.threads_count as i32,
|
||||||
|
reactions_count: s.reactions_count as i32,
|
||||||
|
mentions_count: s.mentions_count as i32,
|
||||||
|
files_count: s.files_count as i32,
|
||||||
|
last_activity_at: s.last_activity_at.map(Self::to_proto_timestamp),
|
||||||
|
updated_at: Some(Self::to_proto_timestamp(s.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_category_workspace(&self, category_id: Uuid) -> Result<String, Status> {
|
||||||
|
let workspace_id: Uuid = sqlx::query_scalar(
|
||||||
|
"SELECT workspace_id FROM channel_category WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(category_id)
|
||||||
|
.fetch_optional(self.service.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
.ok_or_else(|| Status::not_found("category not found"))?;
|
||||||
|
|
||||||
|
self.resolve_workspace_name(workspace_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl ChannelService for ChannelGrpcService {
|
||||||
|
async fn get_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<GetChannelRequest>,
|
||||||
|
) -> Result<Response<GetChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let wk_name = self.resolve_workspace_name(channel.workspace_id).await?;
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_get(&session, &wk_name, channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(GetChannelResponse {
|
||||||
|
channel: Some(Self::model_channel_to_proto(channel)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_channels(
|
||||||
|
&self,
|
||||||
|
request: Request<ListChannelsRequest>,
|
||||||
|
) -> Result<Response<ListChannelsResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let channel_type = req.channel_type()
|
||||||
|
.as_str_name()
|
||||||
|
.strip_prefix("CHANNEL_TYPE_")
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.filter(|s| s != "unspecified");
|
||||||
|
let channel_kind = req.channel_kind()
|
||||||
|
.as_str_name()
|
||||||
|
.strip_prefix("CHANNEL_KIND_")
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.filter(|s| s != "unspecified");
|
||||||
|
|
||||||
|
let filters = ChannelListFilters {
|
||||||
|
channel_type,
|
||||||
|
channel_kind,
|
||||||
|
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
archived: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channels = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_list(
|
||||||
|
&session,
|
||||||
|
&req.workspace_name,
|
||||||
|
filters,
|
||||||
|
req.limit as i64,
|
||||||
|
req.offset as i64,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let total = channels.len() as i32;
|
||||||
|
let proto_channels: Vec<_> = channels
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::model_channel_to_proto)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Response::new(ListChannelsResponse {
|
||||||
|
channels: proto_channels,
|
||||||
|
total,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<CreateChannelRequest>,
|
||||||
|
) -> Result<Response<CreateChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let params = CreateChannelParams {
|
||||||
|
name: req.name,
|
||||||
|
topic: req.topic,
|
||||||
|
description: req.description,
|
||||||
|
channel_type: req.channel_type,
|
||||||
|
channel_kind: req.channel_kind,
|
||||||
|
visibility: req.visibility,
|
||||||
|
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
parent_channel_id: req.parent_channel_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
nsfw: None,
|
||||||
|
rate_limit_per_user: req.rate_limit_per_user,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_create(&session, &req.workspace_name, params, Uuid::nil())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(CreateChannelResponse {
|
||||||
|
channel: Some(Self::model_channel_to_proto(channel)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<UpdateChannelRequest>,
|
||||||
|
) -> Result<Response<UpdateChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let existing = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
let wk_name = self.resolve_workspace_name(existing.workspace_id).await?;
|
||||||
|
|
||||||
|
let params = UpdateChannelParams {
|
||||||
|
name: req.name,
|
||||||
|
topic: req.topic,
|
||||||
|
description: req.description,
|
||||||
|
visibility: req.visibility,
|
||||||
|
category_id: req.category_id.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
position: req.position,
|
||||||
|
nsfw: req.nsfw,
|
||||||
|
rate_limit_per_user: req.rate_limit_per_user,
|
||||||
|
archived: req.archived,
|
||||||
|
read_only: req.read_only,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_update(&session, &wk_name, channel_id, params, Uuid::nil())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(UpdateChannelResponse {
|
||||||
|
channel: Some(Self::model_channel_to_proto(channel)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<DeleteChannelRequest>,
|
||||||
|
) -> Result<Response<DeleteChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let existing = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
let wk_name = self.resolve_workspace_name(existing.workspace_id).await?;
|
||||||
|
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.channel_delete(&session, &wk_name, channel_id, Uuid::nil())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(DeleteChannelResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_channel_stats(
|
||||||
|
&self,
|
||||||
|
request: Request<GetChannelStatsRequest>,
|
||||||
|
) -> Result<Response<GetChannelStatsResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
|
||||||
|
let stats = sqlx::query_as::<_, ChannelStats>(
|
||||||
|
"SELECT * FROM channel_stats WHERE channel_id = $1",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_optional(self.service.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
match stats {
|
||||||
|
Some(s) => Ok(Response::new(GetChannelStatsResponse {
|
||||||
|
stats: Some(Self::model_stats_to_proto(s)),
|
||||||
|
})),
|
||||||
|
None => Err(Status::not_found("Channel stats not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_categories(
|
||||||
|
&self,
|
||||||
|
request: Request<ListCategoriesRequest>,
|
||||||
|
) -> Result<Response<ListCategoriesResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let categories = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.category_list(&session, &req.workspace_name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let proto_categories: Vec<_> = categories
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::model_category_to_proto)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Response::new(ListCategoriesResponse {
|
||||||
|
categories: proto_categories,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category(
|
||||||
|
&self,
|
||||||
|
request: Request<CreateCategoryRequest>,
|
||||||
|
) -> Result<Response<CreateCategoryResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let session = Self::system_session();
|
||||||
|
|
||||||
|
let params = CreateCategoryParams {
|
||||||
|
name: req.name,
|
||||||
|
position: req.position,
|
||||||
|
};
|
||||||
|
|
||||||
|
let category = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.category_create(&session, &req.workspace_name, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(CreateCategoryResponse {
|
||||||
|
category: Some(Self::model_category_to_proto(category)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category(
|
||||||
|
&self,
|
||||||
|
request: Request<UpdateCategoryRequest>,
|
||||||
|
) -> Result<Response<UpdateCategoryResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let category_id = Self::parse_uuid(&req.category_id, "category_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
let wk_name = self.resolve_category_workspace(category_id).await?;
|
||||||
|
|
||||||
|
let params = UpdateCategoryParams {
|
||||||
|
name: req.name,
|
||||||
|
position: req.position,
|
||||||
|
collapsed: req.collapsed,
|
||||||
|
};
|
||||||
|
|
||||||
|
let category = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.category_update(&session, &wk_name, category_id, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(UpdateCategoryResponse {
|
||||||
|
category: Some(Self::model_category_to_proto(category)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_category(
|
||||||
|
&self,
|
||||||
|
request: Request<DeleteCategoryRequest>,
|
||||||
|
) -> Result<Response<DeleteCategoryResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let category_id = Self::parse_uuid(&req.category_id, "category_id")?;
|
||||||
|
let session = Self::system_session();
|
||||||
|
let wk_name = self.resolve_category_workspace(category_id).await?;
|
||||||
|
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.category_delete(&session, &wk_name, category_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(DeleteCategoryResponse {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+243
@@ -0,0 +1,243 @@
|
|||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::channels::ChannelMember;
|
||||||
|
use crate::pb::im::member_service_server::MemberService;
|
||||||
|
use crate::pb::im::{
|
||||||
|
ChannelMember as PbChannelMember, InviteMemberRequest, InviteMemberResponse,
|
||||||
|
IsMemberRequest, IsMemberResponse, JoinChannelRequest, JoinChannelResponse,
|
||||||
|
KickMemberRequest, KickMemberResponse, LeaveChannelRequest, LeaveChannelResponse,
|
||||||
|
ListMembersRequest, ListMembersResponse, UpdateMemberRequest, UpdateMemberResponse,
|
||||||
|
};
|
||||||
|
use crate::service::im::session::ImSession;
|
||||||
|
use crate::service::im::members::{InviteMemberParams, UpdateMemberParams};
|
||||||
|
use crate::service::AppService;
|
||||||
|
|
||||||
|
pub struct MemberGrpcService {
|
||||||
|
service: AppService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemberGrpcService {
|
||||||
|
pub fn new(service: AppService) -> Self {
|
||||||
|
Self { service }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_workspace_name(&self, channel_id: Uuid) -> Result<String, Status> {
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let ws_name: String = sqlx::query_scalar("SELECT name FROM workspace WHERE id = $1")
|
||||||
|
.bind(channel.workspace_id)
|
||||||
|
.fetch_optional(self.service.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
.ok_or_else(|| Status::not_found("workspace not found"))?;
|
||||||
|
|
||||||
|
Ok(ws_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
|
||||||
|
Uuid::parse_str(s).map_err(|_| Status::invalid_argument(format!("invalid {}", field)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_timestamp(dt: chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
|
||||||
|
prost_types::Timestamp {
|
||||||
|
seconds: dt.timestamp(),
|
||||||
|
nanos: dt.timestamp_subsec_nanos() as i32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_pb_member(m: ChannelMember) -> PbChannelMember {
|
||||||
|
PbChannelMember {
|
||||||
|
id: m.id.to_string(),
|
||||||
|
channel_id: m.channel_id.to_string(),
|
||||||
|
user_id: m.user_id.to_string(),
|
||||||
|
role: m.role.to_string(),
|
||||||
|
status: m.status.to_string(),
|
||||||
|
muted: m.muted,
|
||||||
|
pinned: m.pinned,
|
||||||
|
last_read_message_id: m.last_read_message_id.map(|id| id.to_string()),
|
||||||
|
last_read_at: m.last_read_at.map(Self::to_timestamp),
|
||||||
|
joined_at: m.joined_at.map(Self::to_timestamp),
|
||||||
|
left_at: m.left_at.map(Self::to_timestamp),
|
||||||
|
created_at: Some(Self::to_timestamp(m.created_at)),
|
||||||
|
updated_at: Some(Self::to_timestamp(m.updated_at)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl MemberService for MemberGrpcService {
|
||||||
|
async fn list_members(
|
||||||
|
&self,
|
||||||
|
request: Request<ListMembersRequest>,
|
||||||
|
) -> Result<Response<ListMembersResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let session = ImSession::new(Uuid::nil());
|
||||||
|
let members = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.member_list(&session, &wk_name, channel_id, req.limit as i64, req.offset as i64)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let pb_members: Vec<PbChannelMember> = members.into_iter().map(Self::to_pb_member).collect();
|
||||||
|
let total = pb_members.len() as i32;
|
||||||
|
|
||||||
|
Ok(Response::new(ListMembersResponse {
|
||||||
|
members: pb_members,
|
||||||
|
total,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invite_member(
|
||||||
|
&self,
|
||||||
|
request: Request<InviteMemberRequest>,
|
||||||
|
) -> Result<Response<InviteMemberResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let params = InviteMemberParams {
|
||||||
|
user_id,
|
||||||
|
role: req.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = ImSession::new(Uuid::nil());
|
||||||
|
let member = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.member_invite(&session, &wk_name, channel_id, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(InviteMemberResponse {
|
||||||
|
member: Some(Self::to_pb_member(member)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_member(
|
||||||
|
&self,
|
||||||
|
request: Request<UpdateMemberRequest>,
|
||||||
|
) -> Result<Response<UpdateMemberResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let params = UpdateMemberParams {
|
||||||
|
role: req.role,
|
||||||
|
muted: req.muted,
|
||||||
|
pinned: req.pinned,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = ImSession::new(Uuid::nil());
|
||||||
|
let member = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.member_update(&session, &wk_name, channel_id, user_id, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(UpdateMemberResponse {
|
||||||
|
member: Some(Self::to_pb_member(member)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kick_member(
|
||||||
|
&self,
|
||||||
|
request: Request<KickMemberRequest>,
|
||||||
|
) -> Result<Response<KickMemberResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let session = ImSession::new(Uuid::nil());
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.member_kick(&session, &wk_name, channel_id, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(KickMemberResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn join_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<JoinChannelRequest>,
|
||||||
|
) -> Result<Response<JoinChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let session = ImSession::new(user_id);
|
||||||
|
let member = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.member_join(&session, &wk_name, channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(JoinChannelResponse {
|
||||||
|
member: Some(Self::to_pb_member(member)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn leave_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<LeaveChannelRequest>,
|
||||||
|
) -> Result<Response<LeaveChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
let wk_name = self.resolve_workspace_name(channel_id).await?;
|
||||||
|
|
||||||
|
let session = ImSession::new(user_id);
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.member_leave(&session, &wk_name, channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(LeaveChannelResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_member(
|
||||||
|
&self,
|
||||||
|
request: Request<IsMemberRequest>,
|
||||||
|
) -> Result<Response<IsMemberResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_id = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
|
||||||
|
let is_member = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.is_channel_member(channel_id, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let role = if is_member {
|
||||||
|
self.service
|
||||||
|
.im
|
||||||
|
.channel_member_role(channel_id, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Response::new(IsMemberResponse { is_member, role }))
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod channel;
|
||||||
|
pub mod channel_settings;
|
||||||
|
pub mod member;
|
||||||
|
pub mod permission;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::pb::core::token_service_server::TokenServiceServer;
|
||||||
|
use crate::pb::im::channel_audit_service_server::ChannelAuditServiceServer;
|
||||||
|
use crate::pb::im::channel_invitation_service_server::ChannelInvitationServiceServer;
|
||||||
|
use crate::pb::im::channel_repo_link_service_server::ChannelRepoLinkServiceServer;
|
||||||
|
use crate::pb::im::channel_role_service_server::ChannelRoleServiceServer;
|
||||||
|
use crate::pb::im::channel_service_server::ChannelServiceServer;
|
||||||
|
use crate::pb::im::channel_slash_command_service_server::ChannelSlashCommandServiceServer;
|
||||||
|
use crate::pb::im::channel_webhook_service_server::ChannelWebhookServiceServer;
|
||||||
|
use crate::pb::im::custom_emoji_service_server::CustomEmojiServiceServer;
|
||||||
|
use crate::pb::im::forum_tag_service_server::ForumTagServiceServer;
|
||||||
|
use crate::pb::im::im_integration_service_server::ImIntegrationServiceServer;
|
||||||
|
use crate::pb::im::member_service_server::MemberServiceServer;
|
||||||
|
use crate::pb::im::permission_service_server::PermissionServiceServer;
|
||||||
|
use crate::pb::im::stage_service_server::StageServiceServer;
|
||||||
|
use crate::pb::im::voice_service_server::VoiceServiceServer;
|
||||||
|
use tonic_health::ServingStatus;
|
||||||
|
|
||||||
|
use crate::service::AppService;
|
||||||
|
|
||||||
|
pub async fn start_grpc_server(
|
||||||
|
addr: SocketAddr,
|
||||||
|
listener: tokio::net::TcpListener,
|
||||||
|
service: AppService,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let token_svc = auth::TokenGrpcService::new(service.internal_auth.clone());
|
||||||
|
let channel_svc = channel::ChannelGrpcService::new(service.clone());
|
||||||
|
let member_svc = member::MemberGrpcService::new(service.clone());
|
||||||
|
let permission_svc = permission::PermissionGrpcService::new(service.clone());
|
||||||
|
|
||||||
|
let cs = channel_settings::ChannelSettingsServices::new(service);
|
||||||
|
|
||||||
|
let (health_reporter, health_service) = tonic_health::server::health_reporter();
|
||||||
|
health_reporter
|
||||||
|
.set_service_status("", ServingStatus::Serving)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::info!(%addr, "gRPC server listening");
|
||||||
|
|
||||||
|
tonic::transport::Server::builder()
|
||||||
|
.add_service(health_service)
|
||||||
|
.add_service(TokenServiceServer::new(token_svc))
|
||||||
|
.add_service(ChannelServiceServer::new(channel_svc))
|
||||||
|
.add_service(MemberServiceServer::new(member_svc))
|
||||||
|
.add_service(PermissionServiceServer::new(permission_svc))
|
||||||
|
.add_service(ChannelRoleServiceServer::new(cs.channel_role))
|
||||||
|
.add_service(ChannelInvitationServiceServer::new(cs.channel_invitation))
|
||||||
|
.add_service(ChannelWebhookServiceServer::new(cs.channel_webhook))
|
||||||
|
.add_service(ChannelSlashCommandServiceServer::new(cs.channel_slash_command))
|
||||||
|
.add_service(ChannelRepoLinkServiceServer::new(cs.channel_repo_link))
|
||||||
|
.add_service(ImIntegrationServiceServer::new(cs.im_integration))
|
||||||
|
.add_service(CustomEmojiServiceServer::new(cs.custom_emoji))
|
||||||
|
.add_service(ForumTagServiceServer::new(cs.forum_tag))
|
||||||
|
.add_service(VoiceServiceServer::new(cs.voice))
|
||||||
|
.add_service(StageServiceServer::new(cs.stage))
|
||||||
|
.add_service(ChannelAuditServiceServer::new(cs.channel_audit))
|
||||||
|
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
use tonic::{Request, Response, Status};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::channels::ChannelPermissionOverwrite;
|
||||||
|
use crate::models::common::{OverwriteTarget, Role};
|
||||||
|
use crate::pb::im::permission_service_server::PermissionService;
|
||||||
|
use crate::pb::im::{
|
||||||
|
CheckPermissionRequest, CheckPermissionResponse, DeletePermissionOverwriteRequest,
|
||||||
|
DeletePermissionOverwriteResponse, EnsureReadableRequest, EnsureReadableResponse,
|
||||||
|
GetPermissionOverwritesRequest, GetPermissionOverwritesResponse, GetPermissionsRequest,
|
||||||
|
GetPermissionsResponse, PermissionOverwrite, ResolveChannelRequest, ResolveChannelResponse,
|
||||||
|
SetPermissionOverwriteRequest, SetPermissionOverwriteResponse,
|
||||||
|
};
|
||||||
|
use crate::service::util::role_level;
|
||||||
|
use crate::service::AppService;
|
||||||
|
|
||||||
|
pub struct PermissionGrpcService {
|
||||||
|
service: AppService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionGrpcService {
|
||||||
|
pub fn new(service: AppService) -> Self {
|
||||||
|
Self { service }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uuid(s: &str, field: &str) -> Result<Uuid, Status> {
|
||||||
|
Uuid::parse_str(s).map_err(|e| Status::invalid_argument(format!("{field}: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn im_permission_to_str(v: i32) -> &'static str {
|
||||||
|
match v {
|
||||||
|
1 => "READ_CHANNEL",
|
||||||
|
2 => "SEND_MESSAGE",
|
||||||
|
3 => "MANAGE_THREADS",
|
||||||
|
4 => "MANAGE_REACTIONS",
|
||||||
|
5 => "MANAGE_PINS",
|
||||||
|
6 => "INVITE_MEMBERS",
|
||||||
|
7 => "KICK_MEMBERS",
|
||||||
|
8 => "MANAGE_CHANNEL",
|
||||||
|
9 => "MANAGE_ROLES",
|
||||||
|
10 => "MANAGE_WEBHOOKS",
|
||||||
|
11 => "MANAGE_EMOJIS",
|
||||||
|
12 => "VIEW_AUDIT_LOG",
|
||||||
|
13 => "MANAGE_INTEGRATIONS",
|
||||||
|
14 => "SEND_TTS",
|
||||||
|
15 => "USE_SLASH_COMMANDS",
|
||||||
|
16 => "ATTACH_FILES",
|
||||||
|
17 => "MENTION_EVERYONE",
|
||||||
|
18 => "MANAGE_MESSAGES",
|
||||||
|
19 => "ADMIN",
|
||||||
|
_ => "UNSPECIFIED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str_to_im_permission(s: &str) -> i32 {
|
||||||
|
match s {
|
||||||
|
"READ_CHANNEL" => 1,
|
||||||
|
"SEND_MESSAGE" => 2,
|
||||||
|
"MANAGE_THREADS" => 3,
|
||||||
|
"MANAGE_REACTIONS" => 4,
|
||||||
|
"MANAGE_PINS" => 5,
|
||||||
|
"INVITE_MEMBERS" => 6,
|
||||||
|
"KICK_MEMBERS" => 7,
|
||||||
|
"MANAGE_CHANNEL" => 8,
|
||||||
|
"MANAGE_ROLES" => 9,
|
||||||
|
"MANAGE_WEBHOOKS" => 10,
|
||||||
|
"MANAGE_EMOJIS" => 11,
|
||||||
|
"VIEW_AUDIT_LOG" => 12,
|
||||||
|
"MANAGE_INTEGRATIONS" => 13,
|
||||||
|
"SEND_TTS" => 14,
|
||||||
|
"USE_SLASH_COMMANDS" => 15,
|
||||||
|
"ATTACH_FILES" => 16,
|
||||||
|
"MENTION_EVERYONE" => 17,
|
||||||
|
"MANAGE_MESSAGES" => 18,
|
||||||
|
"ADMIN" => 19,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission_requires_role(p: i32) -> Role {
|
||||||
|
match p {
|
||||||
|
1 => Role::Viewer,
|
||||||
|
2 => Role::Member,
|
||||||
|
3 => Role::Member,
|
||||||
|
4 => Role::Member,
|
||||||
|
5 => Role::Moderator,
|
||||||
|
6 => Role::Moderator,
|
||||||
|
7 => Role::Moderator,
|
||||||
|
8 => Role::Admin,
|
||||||
|
9 => Role::Admin,
|
||||||
|
10 => Role::Admin,
|
||||||
|
11 => Role::Admin,
|
||||||
|
12 => Role::Moderator,
|
||||||
|
13 => Role::Admin,
|
||||||
|
14 => Role::Member,
|
||||||
|
15 => Role::Member,
|
||||||
|
16 => Role::Member,
|
||||||
|
17 => Role::Moderator,
|
||||||
|
18 => Role::Moderator,
|
||||||
|
19 => Role::Admin,
|
||||||
|
_ => Role::Owner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_to_permissions(role: Role) -> Vec<i32> {
|
||||||
|
let level = role_level(role);
|
||||||
|
let mut perms = Vec::new();
|
||||||
|
|
||||||
|
if level >= role_level(Role::Viewer) {
|
||||||
|
perms.push(1);
|
||||||
|
}
|
||||||
|
if level >= role_level(Role::Member) {
|
||||||
|
perms.extend_from_slice(&[2, 3, 4, 14, 15, 16]);
|
||||||
|
}
|
||||||
|
if level >= role_level(Role::Moderator) {
|
||||||
|
perms.extend_from_slice(&[5, 6, 7, 12, 17, 18]);
|
||||||
|
}
|
||||||
|
if level >= role_level(Role::Admin) {
|
||||||
|
perms.extend_from_slice(&[8, 9, 10, 11, 13, 19]);
|
||||||
|
}
|
||||||
|
|
||||||
|
perms.sort();
|
||||||
|
perms.dedup();
|
||||||
|
perms
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overwrite_to_proto(o: ChannelPermissionOverwrite) -> PermissionOverwrite {
|
||||||
|
PermissionOverwrite {
|
||||||
|
id: o.id.to_string(),
|
||||||
|
channel_id: o.channel_id.to_string(),
|
||||||
|
target_type: o.target_type.as_str().to_string(),
|
||||||
|
target_id: o.target_id.to_string(),
|
||||||
|
allow: o
|
||||||
|
.allow
|
||||||
|
.iter()
|
||||||
|
.map(|p| Self::str_to_im_permission(p))
|
||||||
|
.collect(),
|
||||||
|
deny: o
|
||||||
|
.deny
|
||||||
|
.iter()
|
||||||
|
.map(|p| Self::str_to_im_permission(p))
|
||||||
|
.collect(),
|
||||||
|
created_at: o.created_at.to_rfc3339(),
|
||||||
|
updated_at: o.updated_at.to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tonic::async_trait]
|
||||||
|
impl PermissionService for PermissionGrpcService {
|
||||||
|
async fn check_permission(
|
||||||
|
&self,
|
||||||
|
request: Request<CheckPermissionRequest>,
|
||||||
|
) -> Result<Response<CheckPermissionResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(e.to_string()))?;
|
||||||
|
|
||||||
|
let role = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_member_role(channel.id, user_uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let required_role = Self::permission_requires_role(req.permission);
|
||||||
|
let allowed = role_level(role) >= role_level(required_role);
|
||||||
|
|
||||||
|
Ok(Response::new(CheckPermissionResponse {
|
||||||
|
allowed,
|
||||||
|
role: role.as_str().to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_permissions(
|
||||||
|
&self,
|
||||||
|
request: Request<GetPermissionsRequest>,
|
||||||
|
) -> Result<Response<GetPermissionsResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(e.to_string()))?;
|
||||||
|
|
||||||
|
let role = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.channel_member_role(channel.id, user_uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let permissions = Self::role_to_permissions(role);
|
||||||
|
|
||||||
|
Ok(Response::new(GetPermissionsResponse {
|
||||||
|
permissions,
|
||||||
|
role: role.as_str().to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_permission_overwrite(
|
||||||
|
&self,
|
||||||
|
request: Request<SetPermissionOverwriteRequest>,
|
||||||
|
) -> Result<Response<SetPermissionOverwriteResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let target_id = Self::parse_uuid(&req.target_id, "target_id")?;
|
||||||
|
let target_type: OverwriteTarget = req.target_type.parse().unwrap_or(OverwriteTarget::Unknown);
|
||||||
|
|
||||||
|
let allow: Vec<String> = req
|
||||||
|
.allow
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| Self::im_permission_to_str(v).to_string())
|
||||||
|
.collect();
|
||||||
|
let deny: Vec<String> = req
|
||||||
|
.deny
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| Self::im_permission_to_str(v).to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let overwrite = sqlx::query_as::<_, ChannelPermissionOverwrite>(
|
||||||
|
"INSERT INTO channel_permission_overwrite \
|
||||||
|
(id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||||
|
ON CONFLICT (channel_id, target_type, target_id) \
|
||||||
|
DO UPDATE SET allow = $5, deny = $6, updated_at = $9 \
|
||||||
|
RETURNING id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(target_type)
|
||||||
|
.bind(target_id)
|
||||||
|
.bind(&allow)
|
||||||
|
.bind(&deny)
|
||||||
|
.bind(Uuid::nil())
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.service.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(SetPermissionOverwriteResponse {
|
||||||
|
overwrite: Some(Self::overwrite_to_proto(overwrite)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_permission_overwrites(
|
||||||
|
&self,
|
||||||
|
request: Request<GetPermissionOverwritesRequest>,
|
||||||
|
) -> Result<Response<GetPermissionOverwritesResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
|
||||||
|
let overwrites = sqlx::query_as::<_, ChannelPermissionOverwrite>(
|
||||||
|
"SELECT id, channel_id, target_type, target_id, allow, deny, created_by, created_at, updated_at \
|
||||||
|
FROM channel_permission_overwrite WHERE channel_id = $1",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_all(self.service.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let proto_overwrites: Vec<_> = overwrites
|
||||||
|
.into_iter()
|
||||||
|
.map(Self::overwrite_to_proto)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Response::new(GetPermissionOverwritesResponse {
|
||||||
|
overwrites: proto_overwrites,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_permission_overwrite(
|
||||||
|
&self,
|
||||||
|
request: Request<DeletePermissionOverwriteRequest>,
|
||||||
|
) -> Result<Response<DeletePermissionOverwriteResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let target_id = Self::parse_uuid(&req.target_id, "target_id")?;
|
||||||
|
let target_type: OverwriteTarget = req.target_type.parse().unwrap_or(OverwriteTarget::Unknown);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM channel_permission_overwrite \
|
||||||
|
WHERE channel_id = $1 AND target_type = $2 AND target_id = $3",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(target_type)
|
||||||
|
.bind(target_id)
|
||||||
|
.execute(self.service.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(DeletePermissionOverwriteResponse {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_channel(
|
||||||
|
&self,
|
||||||
|
request: Request<ResolveChannelRequest>,
|
||||||
|
) -> Result<Response<ResolveChannelResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Response::new(ResolveChannelResponse {
|
||||||
|
channel_id: channel.id.to_string(),
|
||||||
|
workspace_id: channel.workspace_id.to_string(),
|
||||||
|
name: channel.name,
|
||||||
|
visibility: channel.visibility.as_str().to_string(),
|
||||||
|
channel_type: channel.channel_type.as_str().to_string(),
|
||||||
|
read_only: channel.read_only,
|
||||||
|
archived: channel.archived,
|
||||||
|
created_by: Some(channel.created_by.to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_readable(
|
||||||
|
&self,
|
||||||
|
request: Request<EnsureReadableRequest>,
|
||||||
|
) -> Result<Response<EnsureReadableResponse>, Status> {
|
||||||
|
let req = request.into_inner();
|
||||||
|
let channel_id = Self::parse_uuid(&req.channel_id, "channel_id")?;
|
||||||
|
let user_uid = Self::parse_uuid(&req.user_id, "user_id")?;
|
||||||
|
|
||||||
|
let channel = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.resolve_channel(channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::not_found(e.to_string()))?;
|
||||||
|
|
||||||
|
let allowed = self
|
||||||
|
.service
|
||||||
|
.im
|
||||||
|
.ensure_channel_readable(user_uid, &channel)
|
||||||
|
.await
|
||||||
|
.is_ok();
|
||||||
|
|
||||||
|
Ok(Response::new(EnsureReadableResponse { allowed }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::queue::NatsQueue;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
ArticleEvent, CategoryEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent, PollEvent,
|
|
||||||
PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent, WsOutbound, WsSessionManager,
|
|
||||||
WsSinkManager,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct NatsWsBridge {
|
|
||||||
queue: Arc<NatsQueue>,
|
|
||||||
sessions: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NatsWsBridge {
|
|
||||||
pub fn new(
|
|
||||||
queue: Arc<NatsQueue>,
|
|
||||||
sessions: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
queue,
|
|
||||||
sessions,
|
|
||||||
sinks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_ephemeral(self, subject: &str) {
|
|
||||||
let Ok(mut sub) = self.queue.subscribe_ephemeral(subject.to_string()).await else {
|
|
||||||
tracing::warn!(subject, "nats ws bridge subscribe failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
while let Some(msg) = sub.next().await {
|
|
||||||
self.dispatch(msg.subject.as_str(), msg.payload.as_ref(), request_id(&msg))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dispatch(&self, subject: &str, payload: &[u8], request_id: Uuid) {
|
|
||||||
if subject.starts_with("im.message.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Message { request_id, data });
|
|
||||||
} else if subject.starts_with("im.thread.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Thread { request_id, data });
|
|
||||||
} else if subject.starts_with("im.member.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Member { request_id, data });
|
|
||||||
} else if subject.starts_with("im.reaction.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Reaction { request_id, data });
|
|
||||||
} else if subject.starts_with("im.poll.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Poll { request_id, data });
|
|
||||||
} else if subject.starts_with("im.article.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Article { request_id, data });
|
|
||||||
} else if subject.starts_with("im.typing.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Typing { request_id, data });
|
|
||||||
} else if subject.starts_with("im.presence.") {
|
|
||||||
self.presence_event(payload, request_id);
|
|
||||||
} else if subject.starts_with("im.channel.") {
|
|
||||||
self.channel_meta_event(subject, payload, request_id);
|
|
||||||
} else if subject.starts_with("im.category.") {
|
|
||||||
self.category_event(payload, request_id);
|
|
||||||
} else if subject.starts_with("im.draft.") {
|
|
||||||
self.draft_event(payload, request_id);
|
|
||||||
} else if subject.starts_with("im.follow.") {
|
|
||||||
self.channel_event(payload, |data| WsOutbound::Follow { request_id, data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn channel_event<T, F>(&self, payload: &[u8], build: F)
|
|
||||||
where
|
|
||||||
T: serde::de::DeserializeOwned + ChannelScoped,
|
|
||||||
F: Fn(T) -> WsOutbound,
|
|
||||||
{
|
|
||||||
let Ok(data) = serde_json::from_slice::<T>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode channel event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let channel_id = data.channel_id();
|
|
||||||
let subscribers = self.sessions.subscribers(channel_id);
|
|
||||||
let delivered = self.sinks.send_many(subscribers, build(data));
|
|
||||||
tracing::debug!(%channel_id, delivered, "nats event forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn presence_event(&self, payload: &[u8], request_id: Uuid) {
|
|
||||||
let Ok(data) = serde_json::from_slice::<PresenceEvent>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode presence event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let ids = self.sessions.user_connections(data.user_id);
|
|
||||||
let delivered = self
|
|
||||||
.sinks
|
|
||||||
.send_many(ids, WsOutbound::Presence { request_id, data });
|
|
||||||
tracing::debug!(delivered, "nats presence forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn category_event(&self, payload: &[u8], request_id: Uuid) {
|
|
||||||
let Ok(data) = serde_json::from_slice::<CategoryEvent>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode category event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let targets = self.sessions.workspace_connections(&data.workspace_name);
|
|
||||||
let delivered = self
|
|
||||||
.sinks
|
|
||||||
.send_many(targets, WsOutbound::Category { request_id, data });
|
|
||||||
tracing::debug!(delivered, "nats category event forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draft_event(&self, payload: &[u8], request_id: Uuid) {
|
|
||||||
let Ok(data) = serde_json::from_slice::<DraftEvent>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode draft event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let targets = self.sessions.user_connections(data.user_id);
|
|
||||||
let delivered = self
|
|
||||||
.sinks
|
|
||||||
.send_many(targets, WsOutbound::Draft { request_id, data });
|
|
||||||
tracing::debug!(delivered, "nats draft event forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn channel_meta_event(&self, subject: &str, payload: &[u8], request_id: Uuid) {
|
|
||||||
let Ok(data) = serde_json::from_slice::<super::ChannelEvent>(payload) else {
|
|
||||||
tracing::warn!("nats ws bridge decode channel event failed");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut targets = data
|
|
||||||
.workspace_name
|
|
||||||
.as_deref()
|
|
||||||
.map(|workspace| self.sessions.workspace_connections(workspace))
|
|
||||||
.unwrap_or_else(|| self.sessions.subscribers(data.channel_id));
|
|
||||||
if targets.is_empty()
|
|
||||||
&& let Some(id) = subject
|
|
||||||
.rsplit('.')
|
|
||||||
.next()
|
|
||||||
.and_then(|v| v.parse::<Uuid>().ok())
|
|
||||||
{
|
|
||||||
targets = self.sessions.subscribers(id);
|
|
||||||
}
|
|
||||||
let delivered = self
|
|
||||||
.sinks
|
|
||||||
.send_many(targets, WsOutbound::Channel { request_id, data });
|
|
||||||
tracing::debug!(delivered, "nats channel event forwarded to ws subscribers");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait ChannelScoped {
|
|
||||||
fn channel_id(&self) -> Uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelScoped for MessageEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for ThreadEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for MemberEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for ReactionEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for PollEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for ArticleEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for TypingEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl ChannelScoped for FollowEvent {
|
|
||||||
fn channel_id(&self) -> Uuid {
|
|
||||||
self.channel_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_id(msg: &async_nats::Message) -> Uuid {
|
|
||||||
msg.headers
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|h| h.get("X-Request-Id"))
|
|
||||||
.and_then(|v| v.as_str().parse().ok())
|
|
||||||
.unwrap_or_else(Uuid::nil)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
|
|
||||||
pub struct DedupManager {
|
|
||||||
redis: AppRedis,
|
|
||||||
window_secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DedupManager {
|
|
||||||
pub fn new(redis: AppRedis) -> Self {
|
|
||||||
Self {
|
|
||||||
redis,
|
|
||||||
window_secs: WS_DEDUP_WINDOW_SECS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_and_mark(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<bool> {
|
|
||||||
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let result: Option<String> = Cmd::new()
|
|
||||||
.arg("SET")
|
|
||||||
.arg(&key)
|
|
||||||
.arg("1")
|
|
||||||
.arg("NX")
|
|
||||||
.arg("EX")
|
|
||||||
.arg(self.window_secs)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
Ok(result.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_duplicate(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<bool> {
|
|
||||||
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let exists: bool = Cmd::new()
|
|
||||||
.arg("EXISTS")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
Ok(exists)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&self, message_id: Uuid, channel_id: Uuid) -> AppResult<()> {
|
|
||||||
let key = format!("{WS_DEDUP_PREFIX}{channel_id}:{message_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TransportEnvelope<T> {
|
|
||||||
#[serde(default = "Uuid::now_v7")]
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub request_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub payload: T,
|
|
||||||
#[serde(default = "default_timestamp")]
|
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub attempt: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_timestamp() -> chrono::DateTime<chrono::Utc> {
|
|
||||||
chrono::Utc::now()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> TransportEnvelope<T> {
|
|
||||||
pub fn new(request_id: Uuid, user_id: Uuid, payload: T) -> Self {
|
|
||||||
Self {
|
|
||||||
message_id: Uuid::now_v7(),
|
|
||||||
request_id,
|
|
||||||
user_id,
|
|
||||||
payload,
|
|
||||||
created_at: chrono::Utc::now(),
|
|
||||||
attempt: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn retry(self) -> Self {
|
|
||||||
Self {
|
|
||||||
attempt: self.attempt + 1,
|
|
||||||
..self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::immediate::dedup::DedupManager;
|
|
||||||
use crate::immediate::limiter::HandlerLimiter;
|
|
||||||
use crate::immediate::nats::ImNats;
|
|
||||||
use crate::immediate::outbound::*;
|
|
||||||
use crate::immediate::rate_limit::{LocalRateLimiter, RateLimiter};
|
|
||||||
use crate::immediate::reconnect::ReconnectManager;
|
|
||||||
use crate::immediate::session::{WsSession, WsSessionManager};
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::messages::EditMessageParams;
|
|
||||||
use crate::service::im::messages::SendMessageParams;
|
|
||||||
use crate::service::im::presence::UpdatePresenceParams;
|
|
||||||
use crate::service::im::session::ImSession;
|
|
||||||
|
|
||||||
use super::inbound::WsInbound;
|
|
||||||
use super::redis_keys::*;
|
|
||||||
use super::sink::WsSinkManager;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WsHandler {
|
|
||||||
nats: Arc<ImNats>,
|
|
||||||
manager: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
service: ImService,
|
|
||||||
dedup: Arc<DedupManager>,
|
|
||||||
rate_limiter: Arc<RateLimiter>,
|
|
||||||
local_limiter: Arc<LocalRateLimiter>,
|
|
||||||
handler_limiter: Arc<HandlerLimiter>,
|
|
||||||
reconnect: Arc<ReconnectManager>,
|
|
||||||
session: Option<WsSession>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl WsHandler {
|
|
||||||
pub fn new(
|
|
||||||
manager: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
service: ImService,
|
|
||||||
nats: Arc<ImNats>,
|
|
||||||
dedup: Arc<DedupManager>,
|
|
||||||
rate_limiter: Arc<RateLimiter>,
|
|
||||||
reconnect: Arc<ReconnectManager>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
nats,
|
|
||||||
manager,
|
|
||||||
sinks,
|
|
||||||
service,
|
|
||||||
dedup,
|
|
||||||
rate_limiter,
|
|
||||||
local_limiter: Arc::new(LocalRateLimiter::new(WS_MAX_MESSAGES_PER_SEC)),
|
|
||||||
handler_limiter: Arc::new(HandlerLimiter::new(1024)),
|
|
||||||
reconnect,
|
|
||||||
session: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn session(&self) -> Option<&WsSession> {
|
|
||||||
self.session.as_ref()
|
|
||||||
}
|
|
||||||
pub fn is_authenticated(&self) -> bool {
|
|
||||||
self.session.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_disconnect(&self) {
|
|
||||||
if let Some(s) = &self.session
|
|
||||||
&& let Err(e) = self.manager.unregister_connection(s)
|
|
||||||
{
|
|
||||||
tracing::warn!(conn = %s.connection_id, error = %e, "unregister failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle(&mut self, msg: WsInbound) -> Vec<WsOutbound> {
|
|
||||||
match msg {
|
|
||||||
WsInbound::Auth { request_id, token } => self.handle_auth(request_id, token).await,
|
|
||||||
m => {
|
|
||||||
let Some(s) = &self.session else {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "not_authenticated".into(),
|
|
||||||
message: "authenticate first".into(),
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
if !self.manager.is_deliverable(s.connection_id) {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "session_not_active".into(),
|
|
||||||
message: "session is not active".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
let Ok(_permit) = self.handler_limiter.try_acquire() else {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "overloaded".into(),
|
|
||||||
message: "too many inflight messages".into(),
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
if !self.local_limiter.check() {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "rate_limit_exceeded".into(),
|
|
||||||
message: "too many messages".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
match self.rate_limiter.check(s.connection_id) {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id: request_id_of(&m),
|
|
||||||
code: "rate_limit_exceeded".into(),
|
|
||||||
message: "too many messages".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, "rate limit check failed"),
|
|
||||||
}
|
|
||||||
self.dispatch(s, m).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dispatch(&self, session: &WsSession, msg: WsInbound) -> Vec<WsOutbound> {
|
|
||||||
match msg {
|
|
||||||
WsInbound::Heartbeat { request_id } => {
|
|
||||||
if let Err(e) = self.manager.heartbeat(session) {
|
|
||||||
tracing::warn!(user = %session.user_id, error = %e, "heartbeat failed");
|
|
||||||
}
|
|
||||||
vec![WsOutbound::HeartbeatAck {
|
|
||||||
request_id,
|
|
||||||
timestamp_ms: chrono::Utc::now().timestamp_millis(),
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
WsInbound::JoinChannel {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
} => match self.service.resolve_channel(channel_id).await {
|
|
||||||
Ok(channel) => match self
|
|
||||||
.service
|
|
||||||
.ensure_channel_readable(session.user_id, &channel)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => {
|
|
||||||
self.manager
|
|
||||||
.subscribe_channel(session.connection_id, channel_id);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "join_channel_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "join_channel_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
WsInbound::LeaveChannel {
|
|
||||||
request_id: _,
|
|
||||||
channel_id,
|
|
||||||
} => {
|
|
||||||
self.manager
|
|
||||||
.unsubscribe_channel(session.connection_id, channel_id);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
WsInbound::TypingStart {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
} => {
|
|
||||||
let _ = self
|
|
||||||
.manager
|
|
||||||
.set_typing(channel_id, thread_id, session.user_id);
|
|
||||||
self.nats
|
|
||||||
.emit(
|
|
||||||
&ImNats::typing_subject(channel_id),
|
|
||||||
request_id,
|
|
||||||
&TypingEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
user_id: session.user_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
WsInbound::TypingStop {
|
|
||||||
request_id: _,
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
} => {
|
|
||||||
let _ = self
|
|
||||||
.manager
|
|
||||||
.clear_typing(channel_id, thread_id, session.user_id);
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
WsInbound::MessageSend {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
body,
|
|
||||||
thread_id,
|
|
||||||
reply_to,
|
|
||||||
message_type,
|
|
||||||
} => {
|
|
||||||
if body.len() > WS_MAX_MESSAGE_BYTES {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_too_large".into(),
|
|
||||||
message: "message body too large".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
match self.dedup.check_and_mark(request_id, channel_id) {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "duplicate".into(),
|
|
||||||
message: "duplicate message".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, "dedup check failed"),
|
|
||||||
}
|
|
||||||
let ctx = ImSession::new(session.user_id);
|
|
||||||
let params = SendMessageParams {
|
|
||||||
body,
|
|
||||||
message_type,
|
|
||||||
thread_id,
|
|
||||||
reply_to_message_id: reply_to,
|
|
||||||
pinned: None,
|
|
||||||
attachments: None,
|
|
||||||
embeds: None,
|
|
||||||
};
|
|
||||||
match self
|
|
||||||
.service
|
|
||||||
.message_send(
|
|
||||||
&ctx,
|
|
||||||
&session.workspace_name,
|
|
||||||
channel_id,
|
|
||||||
params,
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(msg) => vec![WsOutbound::SeqAck {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
seq: msg.seq,
|
|
||||||
}],
|
|
||||||
Err(e) => {
|
|
||||||
if let Err(clear_err) = self.dedup.clear(request_id, channel_id) {
|
|
||||||
tracing::warn!(error = %clear_err, "dedup clear failed after message send error");
|
|
||||||
}
|
|
||||||
vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_send_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::MessageEdit {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
body,
|
|
||||||
} => {
|
|
||||||
if body.len() > WS_MAX_MESSAGE_BYTES {
|
|
||||||
return vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_too_large".into(),
|
|
||||||
message: "message body too large".into(),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
let ctx = ImSession::new(session.user_id);
|
|
||||||
let params = EditMessageParams { body };
|
|
||||||
match self
|
|
||||||
.service
|
|
||||||
.message_edit(
|
|
||||||
&ctx,
|
|
||||||
&session.workspace_name,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
params,
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => vec![],
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_edit_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::MessageDelete {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
} => {
|
|
||||||
let ctx = ImSession::new(session.user_id);
|
|
||||||
match self
|
|
||||||
.service
|
|
||||||
.message_delete(
|
|
||||||
&ctx,
|
|
||||||
&session.workspace_name,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
request_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => vec![],
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "message_delete_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::PresenceUpdate {
|
|
||||||
request_id,
|
|
||||||
status,
|
|
||||||
custom_status_text,
|
|
||||||
custom_status_emoji,
|
|
||||||
} => {
|
|
||||||
let ctx = ImSession::new(session.user_id);
|
|
||||||
let params = UpdatePresenceParams {
|
|
||||||
status,
|
|
||||||
custom_status_text: custom_status_text.clone(),
|
|
||||||
custom_status_emoji: custom_status_emoji.clone(),
|
|
||||||
};
|
|
||||||
match self
|
|
||||||
.service
|
|
||||||
.presence_update(&ctx, &session.workspace_name, params)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(p) => {
|
|
||||||
self.nats
|
|
||||||
.emit(
|
|
||||||
&ImNats::presence_subject(session.user_id),
|
|
||||||
request_id,
|
|
||||||
&PresenceEvent {
|
|
||||||
user_id: session.user_id,
|
|
||||||
status: p.status.to_string(),
|
|
||||||
custom_status_text,
|
|
||||||
custom_status_emoji,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
Err(e) => vec![WsOutbound::Error {
|
|
||||||
request_id,
|
|
||||||
code: "presence_update_failed".into(),
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::ReadReceipt {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
last_read_message_id,
|
|
||||||
last_seq,
|
|
||||||
} => {
|
|
||||||
if let Some(seq) = last_seq
|
|
||||||
&& let Err(e) =
|
|
||||||
self.reconnect
|
|
||||||
.save_read_position(session.user_id, channel_id, seq)
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "save read position failed");
|
|
||||||
}
|
|
||||||
vec![WsOutbound::ReadReceiptAck {
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
last_read_message_id,
|
|
||||||
last_seq,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
WsInbound::Auth { .. } => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close_replaced_connection(&self, old_id: Uuid, new_id: Uuid) {
|
|
||||||
let _ = self.sinks.send(
|
|
||||||
old_id,
|
|
||||||
WsOutbound::Error {
|
|
||||||
request_id: Uuid::nil(),
|
|
||||||
code: "session_replaced".into(),
|
|
||||||
message: format!("session replaced by {new_id}"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.sinks.detach(old_id);
|
|
||||||
if let Some(old) = self.manager.get_session(old_id)
|
|
||||||
&& let Err(e) = self.manager.unregister_connection(&old)
|
|
||||||
{
|
|
||||||
tracing::warn!(conn = %old_id, error = %e, "unregister replaced connection failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_auth(&mut self, request_id: Uuid, token: String) -> Vec<WsOutbound> {
|
|
||||||
match self.manager.redeem_token(&token) {
|
|
||||||
Ok(session) => {
|
|
||||||
match self.manager.register_connection_with_replacement(&session) {
|
|
||||||
Ok(Some(old_id)) => {
|
|
||||||
self.close_replaced_connection(old_id, session.connection_id)
|
|
||||||
}
|
|
||||||
Ok(None) => {}
|
|
||||||
Err(e) => tracing::warn!(error = %e, "register connection failed"),
|
|
||||||
}
|
|
||||||
let cid = session.connection_id;
|
|
||||||
let interval = self.manager.heartbeat_interval_secs();
|
|
||||||
self.session = Some(session);
|
|
||||||
vec![WsOutbound::AuthOk {
|
|
||||||
request_id,
|
|
||||||
connection_id: cid,
|
|
||||||
heartbeat_interval_secs: interval,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
Err(e) => vec![WsOutbound::AuthError {
|
|
||||||
request_id,
|
|
||||||
message: e.to_string(),
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn request_id_of(msg: &WsInbound) -> Uuid {
|
|
||||||
match msg {
|
|
||||||
WsInbound::Auth { request_id, .. } => *request_id,
|
|
||||||
WsInbound::Heartbeat { request_id } => *request_id,
|
|
||||||
WsInbound::JoinChannel { request_id, .. } => *request_id,
|
|
||||||
WsInbound::LeaveChannel { request_id, .. } => *request_id,
|
|
||||||
WsInbound::TypingStart { request_id, .. } => *request_id,
|
|
||||||
WsInbound::TypingStop { request_id, .. } => *request_id,
|
|
||||||
WsInbound::MessageSend { request_id, .. } => *request_id,
|
|
||||||
WsInbound::MessageEdit { request_id, .. } => *request_id,
|
|
||||||
WsInbound::MessageDelete { request_id, .. } => *request_id,
|
|
||||||
WsInbound::PresenceUpdate { request_id, .. } => *request_id,
|
|
||||||
WsInbound::ReadReceipt { request_id, .. } => *request_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum WsInbound {
|
|
||||||
Auth {
|
|
||||||
request_id: Uuid,
|
|
||||||
token: String,
|
|
||||||
},
|
|
||||||
Heartbeat {
|
|
||||||
request_id: Uuid,
|
|
||||||
},
|
|
||||||
JoinChannel {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
},
|
|
||||||
LeaveChannel {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
},
|
|
||||||
TypingStart {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
},
|
|
||||||
TypingStop {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
},
|
|
||||||
MessageSend {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
body: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
reply_to: Option<Uuid>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
message_type: Option<String>,
|
|
||||||
},
|
|
||||||
MessageEdit {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
body: String,
|
|
||||||
},
|
|
||||||
MessageDelete {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
},
|
|
||||||
PresenceUpdate {
|
|
||||||
request_id: Uuid,
|
|
||||||
status: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
custom_status_text: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
custom_status_emoji: Option<String>,
|
|
||||||
},
|
|
||||||
ReadReceipt {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
last_read_message_id: Uuid,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
last_seq: Option<i64>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct HandlerLimitError;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct HandlerLimiter {
|
|
||||||
sem: Arc<Semaphore>,
|
|
||||||
max_inflight: usize,
|
|
||||||
rejected: Arc<AtomicU64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HandlerLimiter {
|
|
||||||
pub fn new(max_inflight: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
sem: Arc::new(Semaphore::new(max_inflight)),
|
|
||||||
max_inflight,
|
|
||||||
rejected: Arc::new(AtomicU64::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_acquire(&self) -> Result<OwnedSemaphorePermit, HandlerLimitError> {
|
|
||||||
match self.sem.clone().try_acquire_owned() {
|
|
||||||
Ok(permit) => Ok(permit),
|
|
||||||
Err(_) => {
|
|
||||||
self.rejected.fetch_add(1, Ordering::Relaxed);
|
|
||||||
Err(HandlerLimitError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inflight(&self) -> usize {
|
|
||||||
self.max_inflight - self.sem.available_permits()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn available(&self) -> usize {
|
|
||||||
self.sem.available_permits()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rejected_total(&self) -> u64 {
|
|
||||||
self.rejected.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
mod bridge;
|
|
||||||
mod dedup;
|
|
||||||
mod envelope;
|
|
||||||
mod handler;
|
|
||||||
mod inbound;
|
|
||||||
mod limiter;
|
|
||||||
mod nats;
|
|
||||||
mod outbound;
|
|
||||||
mod rate_limit;
|
|
||||||
mod reconnect;
|
|
||||||
mod redis_keys;
|
|
||||||
mod runtime;
|
|
||||||
mod seq;
|
|
||||||
mod session;
|
|
||||||
mod session_redis;
|
|
||||||
mod sink;
|
|
||||||
mod typing;
|
|
||||||
|
|
||||||
pub use bridge::NatsWsBridge;
|
|
||||||
pub use dedup::DedupManager;
|
|
||||||
pub use envelope::TransportEnvelope;
|
|
||||||
pub use inbound::WsInbound;
|
|
||||||
pub use limiter::HandlerLimiter;
|
|
||||||
pub use nats::ImNats;
|
|
||||||
pub use outbound::{
|
|
||||||
ArticleAction, ArticleEvent, CategoryAction, CategoryEvent, ChannelAction, ChannelEvent,
|
|
||||||
DraftAction, DraftEvent, FollowAction, FollowEvent, MemberAction, MemberEvent, MessageAction,
|
|
||||||
MessageEvent, PollAction, PollEvent, PresenceEvent, ReactionAction, ReactionEvent,
|
|
||||||
ThreadAction, ThreadEvent, TypingEvent, WsOutbound,
|
|
||||||
};
|
|
||||||
pub use rate_limit::{LocalRateLimiter, RateLimiter};
|
|
||||||
pub use reconnect::ReconnectManager;
|
|
||||||
pub use runtime::WsRuntime;
|
|
||||||
pub use seq::SeqAllocator;
|
|
||||||
pub use session::{WsSession, WsSessionManager, WsSessionState};
|
|
||||||
pub use sink::{WsReceiver, WsSender, WsSinkManager};
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::queue::NatsQueue;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ImNats {
|
|
||||||
inner: Arc<NatsQueue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImNats {
|
|
||||||
pub fn new(nats: Arc<NatsQueue>) -> Self {
|
|
||||||
Self { inner: nats }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn emit<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
|
|
||||||
if let Err(e) = self
|
|
||||||
.inner
|
|
||||||
.publish_with_headers(
|
|
||||||
subject,
|
|
||||||
&serde_json::to_vec(event).unwrap_or_default(),
|
|
||||||
vec![("X-Request-Id".into(), request_id.to_string())],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(subject, error = %e, "nats emit failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn channel_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.channel.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn message_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.message.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn thread_subject(channel_id: Uuid, thread_id: Uuid) -> String {
|
|
||||||
format!("im.thread.{channel_id}.{thread_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn member_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.member.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn reaction_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.reaction.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn typing_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.typing.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn presence_subject(user_id: Uuid) -> String {
|
|
||||||
format!("im.presence.{user_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn poll_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.poll.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn article_subject(channel_id: Uuid) -> String {
|
|
||||||
format!("im.article.{channel_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn workspace_channels_subject(workspace_name: &str) -> String {
|
|
||||||
format!("im.ws_channels.{workspace_name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum WsOutbound {
|
|
||||||
AuthOk {
|
|
||||||
request_id: Uuid,
|
|
||||||
connection_id: Uuid,
|
|
||||||
heartbeat_interval_secs: u64,
|
|
||||||
},
|
|
||||||
AuthError {
|
|
||||||
request_id: Uuid,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
HeartbeatAck {
|
|
||||||
request_id: Uuid,
|
|
||||||
timestamp_ms: i64,
|
|
||||||
},
|
|
||||||
Error {
|
|
||||||
request_id: Uuid,
|
|
||||||
code: String,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
Typing {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: TypingEvent,
|
|
||||||
},
|
|
||||||
Presence {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: PresenceEvent,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: MessageEvent,
|
|
||||||
},
|
|
||||||
Channel {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ChannelEvent,
|
|
||||||
},
|
|
||||||
Thread {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ThreadEvent,
|
|
||||||
},
|
|
||||||
Member {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: MemberEvent,
|
|
||||||
},
|
|
||||||
Reaction {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ReactionEvent,
|
|
||||||
},
|
|
||||||
Poll {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: PollEvent,
|
|
||||||
},
|
|
||||||
Article {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ArticleEvent,
|
|
||||||
},
|
|
||||||
Category {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: CategoryEvent,
|
|
||||||
},
|
|
||||||
Draft {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: DraftEvent,
|
|
||||||
},
|
|
||||||
Follow {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: FollowEvent,
|
|
||||||
},
|
|
||||||
ReadReceiptAck {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
last_read_message_id: Uuid,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
last_seq: Option<i64>,
|
|
||||||
},
|
|
||||||
SeqAck {
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
seq: i64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TypingEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PresenceEvent {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub status: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub custom_status_text: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub custom_status_emoji: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MessageEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub author_id: Uuid,
|
|
||||||
pub action: MessageAction,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub body: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub seq: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum MessageAction {
|
|
||||||
Created,
|
|
||||||
Edited,
|
|
||||||
Deleted,
|
|
||||||
Pinned,
|
|
||||||
Unpinned,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChannelEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub action: ChannelAction,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub workspace_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ChannelAction {
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ThreadEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub thread_id: Uuid,
|
|
||||||
pub action: ThreadAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ThreadAction {
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MemberEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub action: MemberAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum MemberAction {
|
|
||||||
Joined,
|
|
||||||
Left,
|
|
||||||
Kicked,
|
|
||||||
Updated,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ReactionEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub action: ReactionAction,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub content: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ReactionAction {
|
|
||||||
Added,
|
|
||||||
Removed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PollEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub poll_id: Uuid,
|
|
||||||
pub action: PollAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum PollAction {
|
|
||||||
Created,
|
|
||||||
Voted,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ArticleEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub article_id: Uuid,
|
|
||||||
pub action: ArticleAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ArticleAction {
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
Published,
|
|
||||||
Unpublished,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CategoryEvent {
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub category_id: Uuid,
|
|
||||||
pub action: CategoryAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum CategoryAction {
|
|
||||||
Created,
|
|
||||||
Updated,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DraftEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub action: DraftAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum DraftAction {
|
|
||||||
Saved,
|
|
||||||
Deleted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct FollowEvent {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub follow_id: Uuid,
|
|
||||||
pub action: FollowAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum FollowAction {
|
|
||||||
Created,
|
|
||||||
Deleted,
|
|
||||||
Retried,
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
|
|
||||||
pub struct RateLimiter {
|
|
||||||
redis: AppRedis,
|
|
||||||
max_per_sec: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RateLimiter {
|
|
||||||
pub fn new(redis: AppRedis) -> Self {
|
|
||||||
Self {
|
|
||||||
redis,
|
|
||||||
max_per_sec: WS_MAX_MESSAGES_PER_SEC,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_limit(redis: AppRedis, max_per_sec: u32) -> Self {
|
|
||||||
Self { redis, max_per_sec }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check(&self, connection_id: Uuid) -> AppResult<bool> {
|
|
||||||
let key = format!("{WS_RATE_PREFIX}{connection_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let count: i64 = Cmd::new()
|
|
||||||
.arg("INCR")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
if count == 1 {
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(1_u64)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
}
|
|
||||||
Ok(count <= self.max_per_sec as i64)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_sliding(&self, connection_id: Uuid) -> AppResult<bool> {
|
|
||||||
let key = format!("{WS_RATE_PREFIX}{connection_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let count: i64 = Cmd::new()
|
|
||||||
.arg("INCR")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
if count == 1 {
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(2_u64)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
}
|
|
||||||
Ok(count <= self.max_per_sec as i64)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remaining(&self, connection_id: Uuid) -> AppResult<u32> {
|
|
||||||
let key = format!("{WS_RATE_PREFIX}{connection_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let count: Option<i64> = Cmd::new()
|
|
||||||
.arg("GET")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(crate::error::AppError::Redis)?;
|
|
||||||
Ok(self.max_per_sec.saturating_sub(count.unwrap_or(0) as u32))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LocalRateLimiter {
|
|
||||||
count: std::sync::atomic::AtomicU32,
|
|
||||||
start: std::sync::Mutex<Instant>,
|
|
||||||
max_per_sec: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalRateLimiter {
|
|
||||||
pub fn new(max_per_sec: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
count: std::sync::atomic::AtomicU32::new(0),
|
|
||||||
start: std::sync::Mutex::new(Instant::now()),
|
|
||||||
max_per_sec,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check(&self) -> bool {
|
|
||||||
let mut start = self.start.lock().unwrap();
|
|
||||||
if start.elapsed().as_secs() >= 1 {
|
|
||||||
self.count.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
*start = Instant::now();
|
|
||||||
}
|
|
||||||
drop(start);
|
|
||||||
self.count
|
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
|
||||||
< self.max_per_sec
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
|
|
||||||
pub struct ReconnectManager {
|
|
||||||
redis: AppRedis,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReconnectManager {
|
|
||||||
pub fn new(redis: AppRedis) -> Self {
|
|
||||||
Self { redis }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_read_position(&self, user_id: Uuid, channel_id: Uuid, seq: i64) -> AppResult<()> {
|
|
||||||
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(WS_RECONNECT_STATE_TTL_SECS)
|
|
||||||
.arg(seq.to_string())
|
|
||||||
.query::<()>(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_read_positions(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
positions: &HashMap<Uuid, i64>,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
for (channel_id, seq) in positions {
|
|
||||||
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(WS_RECONNECT_STATE_TTL_SECS)
|
|
||||||
.arg(seq.to_string())
|
|
||||||
.query::<()>(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_last_seq(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<Option<i64>> {
|
|
||||||
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let val: Option<String> = Cmd::new()
|
|
||||||
.arg("GET")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
Ok(val.and_then(|v| v.parse().ok()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_all_positions(&self, user_id: Uuid) -> AppResult<HashMap<Uuid, i64>> {
|
|
||||||
let pattern = format!("{WS_RECONNECT_PREFIX}{user_id}:*");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let keys: Vec<String> = Cmd::new()
|
|
||||||
.arg("KEYS")
|
|
||||||
.arg(&pattern)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
let mut result = HashMap::new();
|
|
||||||
let prefix_len = format!("{WS_RECONNECT_PREFIX}{user_id}:").len();
|
|
||||||
for key in &keys {
|
|
||||||
if let Some(channel_str) = key.get(prefix_len..)
|
|
||||||
&& let Ok(channel_id) = channel_str.parse::<Uuid>()
|
|
||||||
{
|
|
||||||
let val: Option<String> = Cmd::new()
|
|
||||||
.arg("GET")
|
|
||||||
.arg(key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
if let Some(v) = val
|
|
||||||
&& let Ok(seq) = v.parse::<i64>()
|
|
||||||
{
|
|
||||||
result.insert(channel_id, seq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cleanup_channel(&self, user_id: Uuid, channel_id: Uuid) -> AppResult<()> {
|
|
||||||
let key = format!("{WS_RECONNECT_PREFIX}{user_id}:{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
pub const WS_TOKEN_PREFIX: &str = "im:ws:token:";
|
|
||||||
pub const WS_ONLINE_PREFIX: &str = "im:ws:online:";
|
|
||||||
pub const WS_CONNS_PREFIX: &str = "im:ws:conns:";
|
|
||||||
pub const WS_SEQ_PREFIX: &str = "im:seq:";
|
|
||||||
pub const WS_DEDUP_PREFIX: &str = "im:dedup:";
|
|
||||||
pub const WS_RATE_PREFIX: &str = "im:rate:";
|
|
||||||
pub const WS_RECONNECT_PREFIX: &str = "im:reconnect:";
|
|
||||||
|
|
||||||
pub const WS_TOKEN_TTL_SECS: u64 = 30;
|
|
||||||
pub const WS_ONLINE_TTL_SECS: u64 = 60;
|
|
||||||
pub const WS_HEARTBEAT_INTERVAL_SECS: u64 = 30;
|
|
||||||
pub const WS_HEARTBEAT_TIMEOUT_SECS: u64 = 60;
|
|
||||||
pub const WS_MAX_IDLE_SECS: u64 = 300;
|
|
||||||
pub const WS_MAX_MESSAGE_BYTES: usize = 64 * 1024;
|
|
||||||
pub const WS_MAX_MESSAGES_PER_SEC: u32 = 100;
|
|
||||||
pub const WS_SEQ_SEGMENT_SIZE: u64 = 1024;
|
|
||||||
pub const WS_DEDUP_WINDOW_SECS: u64 = 300;
|
|
||||||
pub const WS_RECONNECT_STATE_TTL_SECS: u64 = 86400;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::queue::NatsQueue;
|
|
||||||
|
|
||||||
use super::{NatsWsBridge, WsReceiver, WsSender, WsSessionManager, WsSinkManager};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WsRuntime {
|
|
||||||
sessions: Arc<WsSessionManager>,
|
|
||||||
sinks: Arc<WsSinkManager>,
|
|
||||||
bridge: NatsWsBridge,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsRuntime {
|
|
||||||
pub fn new(queue: Arc<NatsQueue>, sessions: Arc<WsSessionManager>) -> Self {
|
|
||||||
let sinks = Arc::new(WsSinkManager::new());
|
|
||||||
let bridge = NatsWsBridge::new(queue, sessions.clone(), sinks.clone());
|
|
||||||
Self {
|
|
||||||
sessions,
|
|
||||||
sinks,
|
|
||||||
bridge,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sinks(&self) -> Arc<WsSinkManager> {
|
|
||||||
self.sinks.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sessions(&self) -> Arc<WsSessionManager> {
|
|
||||||
self.sessions.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn attach(&self, connection_id: Uuid) -> WsReceiver {
|
|
||||||
let (tx, rx): (WsSender, WsReceiver) = WsSinkManager::channel();
|
|
||||||
self.sinks.attach(connection_id, tx);
|
|
||||||
rx
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn detach(&self, connection_id: Uuid) {
|
|
||||||
self.sinks.detach(connection_id);
|
|
||||||
self.sessions.unsubscribe_all(connection_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_nats_bridge(&self) {
|
|
||||||
let bridge = self.bridge.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
bridge.run_ephemeral("im.>").await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
|
|
||||||
struct Segment {
|
|
||||||
end: i64,
|
|
||||||
next: AtomicI64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SeqAllocator {
|
|
||||||
redis: AppRedis,
|
|
||||||
segments: DashMap<Uuid, Arc<Segment>>,
|
|
||||||
locks: DashMap<Uuid, Arc<Mutex<()>>>,
|
|
||||||
segment_size: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_RETRIES: u32 = 3;
|
|
||||||
|
|
||||||
impl SeqAllocator {
|
|
||||||
pub fn new(redis: AppRedis) -> Self {
|
|
||||||
Self {
|
|
||||||
redis,
|
|
||||||
segments: DashMap::new(),
|
|
||||||
locks: DashMap::new(),
|
|
||||||
segment_size: WS_SEQ_SEGMENT_SIZE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn next(&self, channel_id: Uuid) -> AppResult<i64> {
|
|
||||||
for _ in 0..MAX_RETRIES {
|
|
||||||
if let Some(seq) = self.try_allocate(&channel_id) {
|
|
||||||
return Ok(seq);
|
|
||||||
}
|
|
||||||
let lock = self
|
|
||||||
.locks
|
|
||||||
.entry(channel_id)
|
|
||||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
|
||||||
.clone();
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
if let Some(seq) = self.try_allocate(&channel_id) {
|
|
||||||
return Ok(seq);
|
|
||||||
}
|
|
||||||
self.refresh(channel_id).await?;
|
|
||||||
}
|
|
||||||
Err(AppError::InternalServerError(
|
|
||||||
"seq allocation exhausted retries".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn bootstrap(&self, channel_id: Uuid, db_max: i64) -> AppResult<i64> {
|
|
||||||
let key = format!("{WS_SEQ_PREFIX}{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let current: i64 = Cmd::new()
|
|
||||||
.arg("SET")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(db_max)
|
|
||||||
.arg("NX")
|
|
||||||
.arg("EX")
|
|
||||||
.arg(86400)
|
|
||||||
.query::<Option<String>>(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let existing: i64 = Cmd::new()
|
|
||||||
.arg("GET")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)
|
|
||||||
.unwrap_or(db_max);
|
|
||||||
if existing < db_max { db_max } else { existing }
|
|
||||||
});
|
|
||||||
self.segments.remove(&channel_id);
|
|
||||||
Ok(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_allocate(&self, channel_id: &Uuid) -> Option<i64> {
|
|
||||||
let state = self.segments.get(channel_id)?;
|
|
||||||
let next = state.next.fetch_add(1, Ordering::Relaxed);
|
|
||||||
if next < state.end { Some(next) } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh(&self, channel_id: Uuid) -> AppResult<()> {
|
|
||||||
let key = format!("{WS_SEQ_PREFIX}{channel_id}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let counter: i64 = Cmd::new()
|
|
||||||
.arg("INCRBY")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(self.segment_size as i64)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
|
|
||||||
let start = counter - self.segment_size as i64 + 1;
|
|
||||||
let end = counter + 1;
|
|
||||||
self.segments.insert(
|
|
||||||
channel_id,
|
|
||||||
Arc::new(Segment {
|
|
||||||
end,
|
|
||||||
next: AtomicI64::new(start),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(86400_u64)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::queue::NatsQueue;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
use super::session_redis::{heartbeat_redis, register_redis_online, unregister_redis_online};
|
|
||||||
use super::typing;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum WsSessionState {
|
|
||||||
Connecting,
|
|
||||||
Authenticated,
|
|
||||||
Replaced,
|
|
||||||
Closing,
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsSessionState {
|
|
||||||
pub fn is_deliverable(self) -> bool {
|
|
||||||
matches!(self, Self::Authenticated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WsSession {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub device_id: String,
|
|
||||||
pub connection_id: Uuid,
|
|
||||||
pub workspace_name: String,
|
|
||||||
pub connected_at: i64,
|
|
||||||
pub authenticated_at: Option<i64>,
|
|
||||||
pub state: WsSessionState,
|
|
||||||
pub superseded_by: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WsSessionManager {
|
|
||||||
redis: AppRedis,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
nats: Arc<NatsQueue>,
|
|
||||||
user_devices: Arc<DashMap<Uuid, HashMap<String, Uuid>>>,
|
|
||||||
sessions: Arc<DashMap<Uuid, WsSession>>,
|
|
||||||
channel_routes: Arc<DashMap<Uuid, HashSet<Uuid>>>,
|
|
||||||
session_channels: Arc<DashMap<Uuid, HashSet<Uuid>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsSessionManager {
|
|
||||||
pub fn new(redis: AppRedis, nats: Arc<NatsQueue>) -> Self {
|
|
||||||
Self {
|
|
||||||
redis,
|
|
||||||
nats,
|
|
||||||
user_devices: Arc::new(DashMap::new()),
|
|
||||||
sessions: Arc::new(DashMap::new()),
|
|
||||||
channel_routes: Arc::new(DashMap::new()),
|
|
||||||
session_channels: Arc::new(DashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn issue_token(&self, user_id: Uuid, workspace_name: &str) -> AppResult<String> {
|
|
||||||
self.issue_token_for_device(user_id, workspace_name, "default")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn issue_token_for_device(
|
|
||||||
&self,
|
|
||||||
user_id: Uuid,
|
|
||||||
workspace_name: &str,
|
|
||||||
device_id: &str,
|
|
||||||
) -> AppResult<String> {
|
|
||||||
let token = format!("ws_{}", Uuid::now_v7());
|
|
||||||
let session = WsSession {
|
|
||||||
user_id,
|
|
||||||
device_id: device_id.to_string(),
|
|
||||||
connection_id: Uuid::nil(),
|
|
||||||
workspace_name: workspace_name.to_string(),
|
|
||||||
connected_at: 0,
|
|
||||||
authenticated_at: None,
|
|
||||||
state: WsSessionState::Connecting,
|
|
||||||
superseded_by: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&session)?;
|
|
||||||
let key = format!("{WS_TOKEN_PREFIX}{token}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(WS_TOKEN_TTL_SECS)
|
|
||||||
.arg(&json)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Ok(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn redeem_token(&self, token: &str) -> AppResult<WsSession> {
|
|
||||||
let key = format!("{WS_TOKEN_PREFIX}{token}");
|
|
||||||
let mut conn = self.redis.get_connection()?;
|
|
||||||
let json: Option<String> = Cmd::new()
|
|
||||||
.arg("GETDEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<Option<String>>(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
let json = json.ok_or(AppError::Unauthorized)?;
|
|
||||||
let mut session: WsSession = serde_json::from_str(&json)
|
|
||||||
.map_err(|e| AppError::Config(format!("invalid ws session: {e}")))?;
|
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
|
||||||
session.connection_id = Uuid::now_v7();
|
|
||||||
session.connected_at = now;
|
|
||||||
session.authenticated_at = Some(now);
|
|
||||||
session.state = WsSessionState::Authenticated;
|
|
||||||
session.superseded_by = None;
|
|
||||||
Ok(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register_connection(&self, session: &WsSession) -> AppResult<()> {
|
|
||||||
let _ = self.register_connection_with_replacement(session)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register_connection_with_replacement(
|
|
||||||
&self,
|
|
||||||
session: &WsSession,
|
|
||||||
) -> AppResult<Option<Uuid>> {
|
|
||||||
let mut current = session.clone();
|
|
||||||
current.state = WsSessionState::Authenticated;
|
|
||||||
current.superseded_by = None;
|
|
||||||
self.sessions.insert(current.connection_id, current.clone());
|
|
||||||
|
|
||||||
let replaced = {
|
|
||||||
let mut entry = self.user_devices.entry(current.user_id).or_default();
|
|
||||||
entry.insert(current.device_id.clone(), current.connection_id)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(old_id) = replaced
|
|
||||||
&& old_id != current.connection_id
|
|
||||||
{
|
|
||||||
if let Some(mut old) = self.sessions.get_mut(&old_id) {
|
|
||||||
old.state = WsSessionState::Replaced;
|
|
||||||
old.superseded_by = Some(current.connection_id);
|
|
||||||
}
|
|
||||||
self.unsubscribe_all(old_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
register_redis_online(&self.redis, ¤t)?;
|
|
||||||
Ok(replaced.filter(|old| *old != current.connection_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unregister_connection(&self, session: &WsSession) -> AppResult<()> {
|
|
||||||
let removed = self.sessions.remove(&session.connection_id).map(|(_, s)| s);
|
|
||||||
let current = removed.as_ref().unwrap_or(session);
|
|
||||||
self.unsubscribe_all(current.connection_id);
|
|
||||||
|
|
||||||
if let Some(mut devices) = self.user_devices.get_mut(¤t.user_id)
|
|
||||||
&& devices.get(¤t.device_id).copied() == Some(current.connection_id)
|
|
||||||
{
|
|
||||||
devices.remove(¤t.device_id);
|
|
||||||
}
|
|
||||||
self.user_devices
|
|
||||||
.remove_if(¤t.user_id, |_, devices| devices.is_empty());
|
|
||||||
unregister_redis_online(&self.redis, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heartbeat(&self, session: &WsSession) -> AppResult<()> {
|
|
||||||
if !self.is_deliverable(session.connection_id) {
|
|
||||||
return Err(AppError::Unauthorized);
|
|
||||||
}
|
|
||||||
heartbeat_redis(&self.redis, session)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) {
|
|
||||||
self.channel_routes
|
|
||||||
.entry(channel_id)
|
|
||||||
.or_default()
|
|
||||||
.insert(connection_id);
|
|
||||||
self.session_channels
|
|
||||||
.entry(connection_id)
|
|
||||||
.or_default()
|
|
||||||
.insert(channel_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unsubscribe_channel(&self, connection_id: Uuid, channel_id: Uuid) {
|
|
||||||
if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) {
|
|
||||||
sessions.remove(&connection_id);
|
|
||||||
}
|
|
||||||
self.channel_routes
|
|
||||||
.remove_if(&channel_id, |_, sessions| sessions.is_empty());
|
|
||||||
if let Some(mut channels) = self.session_channels.get_mut(&connection_id) {
|
|
||||||
channels.remove(&channel_id);
|
|
||||||
}
|
|
||||||
self.session_channels
|
|
||||||
.remove_if(&connection_id, |_, channels| channels.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unsubscribe_all(&self, connection_id: Uuid) {
|
|
||||||
let channels = self
|
|
||||||
.session_channels
|
|
||||||
.remove(&connection_id)
|
|
||||||
.map(|(_, channels)| channels)
|
|
||||||
.unwrap_or_default();
|
|
||||||
for channel_id in channels {
|
|
||||||
if let Some(mut sessions) = self.channel_routes.get_mut(&channel_id) {
|
|
||||||
sessions.remove(&connection_id);
|
|
||||||
}
|
|
||||||
self.channel_routes
|
|
||||||
.remove_if(&channel_id, |_, sessions| sessions.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribers(&self, channel_id: Uuid) -> Vec<Uuid> {
|
|
||||||
self.channel_routes
|
|
||||||
.get(&channel_id)
|
|
||||||
.map(|sessions| sessions.iter().copied().collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_connections(&self, user_id: Uuid) -> Vec<Uuid> {
|
|
||||||
self.user_devices
|
|
||||||
.get(&user_id)
|
|
||||||
.map(|devices| devices.values().copied().collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn workspace_connections(&self, workspace_name: &str) -> Vec<Uuid> {
|
|
||||||
self.sessions
|
|
||||||
.iter()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
let session = entry.value();
|
|
||||||
(session.workspace_name == workspace_name && session.state.is_deliverable())
|
|
||||||
.then_some(session.connection_id)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_session(&self, connection_id: Uuid) -> Option<WsSession> {
|
|
||||||
self.sessions
|
|
||||||
.get(&connection_id)
|
|
||||||
.map(|session| session.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_deliverable(&self, connection_id: Uuid) -> bool {
|
|
||||||
self.sessions
|
|
||||||
.get(&connection_id)
|
|
||||||
.map(|session| session.state.is_deliverable() && session.superseded_by.is_none())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_user_online(&self, user_id: Uuid) -> AppResult<bool> {
|
|
||||||
Ok(self
|
|
||||||
.user_devices
|
|
||||||
.get(&user_id)
|
|
||||||
.map(|devices| !devices.is_empty())
|
|
||||||
.unwrap_or(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_connection_count(&self, user_id: Uuid) -> AppResult<u32> {
|
|
||||||
Ok(self
|
|
||||||
.user_devices
|
|
||||||
.get(&user_id)
|
|
||||||
.map(|devices| devices.len() as u32)
|
|
||||||
.unwrap_or(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_typing(
|
|
||||||
&self,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
typing::set_typing(&self.redis, channel_id, thread_id, user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_typing(
|
|
||||||
&self,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
typing::clear_typing(&self.redis, channel_id, thread_id, user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_typing_users(
|
|
||||||
&self,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
) -> AppResult<Vec<Uuid>> {
|
|
||||||
typing::get_typing_users(&self.redis, channel_id, thread_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heartbeat_interval(&self) -> Duration {
|
|
||||||
Duration::from_secs(WS_HEARTBEAT_INTERVAL_SECS)
|
|
||||||
}
|
|
||||||
pub fn heartbeat_interval_secs(&self) -> u64 {
|
|
||||||
WS_HEARTBEAT_INTERVAL_SECS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::service::im::util::PRESENCE_PREFIX;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::redis_keys::*;
|
|
||||||
use super::session::WsSession;
|
|
||||||
|
|
||||||
pub fn register_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
|
|
||||||
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
|
|
||||||
let conn_id = session.connection_id.to_string();
|
|
||||||
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SADD")
|
|
||||||
.arg(&set_key)
|
|
||||||
.arg(&conn_id)
|
|
||||||
.query::<i32>(&mut *conn.inner_mut())?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&set_key)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&meta_key)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.arg(session.workspace_name.as_str())
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unregister_redis_online(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
|
|
||||||
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
|
|
||||||
let conn_id = session.connection_id.to_string();
|
|
||||||
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SREM")
|
|
||||||
.arg(&set_key)
|
|
||||||
.arg(&conn_id)
|
|
||||||
.query::<i32>(&mut *conn.inner_mut())?;
|
|
||||||
|
|
||||||
let remaining: i32 = Cmd::new()
|
|
||||||
.arg("SCARD")
|
|
||||||
.arg(&set_key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
if remaining == 0 {
|
|
||||||
Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&set_key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
let pk = format!("{PRESENCE_PREFIX}{}", session.user_id);
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&pk)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
}
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&meta_key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heartbeat_redis(redis: &AppRedis, session: &WsSession) -> AppResult<()> {
|
|
||||||
let set_key = format!("{WS_ONLINE_PREFIX}{}", session.user_id);
|
|
||||||
let meta_key = format!("{WS_CONNS_PREFIX}{}", session.connection_id);
|
|
||||||
let pk = format!("{PRESENCE_PREFIX}{}", session.user_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("EXPIRE")
|
|
||||||
.arg(&set_key)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&meta_key)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.arg(session.workspace_name.as_str())
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
let _ = Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&pk)
|
|
||||||
.arg(WS_ONLINE_TTL_SECS)
|
|
||||||
.arg("online")
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::WsOutbound;
|
|
||||||
|
|
||||||
pub type WsSender = mpsc::UnboundedSender<WsOutbound>;
|
|
||||||
pub type WsReceiver = mpsc::UnboundedReceiver<WsOutbound>;
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct WsSinkManager {
|
|
||||||
sinks: Arc<DashMap<Uuid, WsSender>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsSinkManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn channel() -> (WsSender, WsReceiver) {
|
|
||||||
mpsc::unbounded_channel()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn attach(&self, connection_id: Uuid, sender: WsSender) {
|
|
||||||
self.sinks.insert(connection_id, sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn detach(&self, connection_id: Uuid) {
|
|
||||||
self.sinks.remove(&connection_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send(&self, connection_id: Uuid, message: WsOutbound) -> bool {
|
|
||||||
self.sinks
|
|
||||||
.get(&connection_id)
|
|
||||||
.map(|sink| sink.send(message).is_ok())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_many<I>(&self, ids: I, message: WsOutbound) -> usize
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = Uuid>,
|
|
||||||
{
|
|
||||||
ids.into_iter()
|
|
||||||
.filter(|id| self.send(*id, message.clone()))
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn contains(&self, connection_id: Uuid) -> bool {
|
|
||||||
self.sinks.contains_key(&connection_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cache::redis::AppRedis;
|
|
||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use crate::service::im::util::{TYPING_PREFIX, TYPING_TTL_SECS};
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
pub fn set_typing(
|
|
||||||
redis: &AppRedis,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let key = typing_key(channel_id, thread_id, user_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(TYPING_TTL_SECS as u64)
|
|
||||||
.arg("1")
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_typing(
|
|
||||||
redis: &AppRedis,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let key = typing_key(channel_id, thread_id, user_id);
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
Cmd::new()
|
|
||||||
.arg("DEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_typing_users(
|
|
||||||
redis: &AppRedis,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
) -> AppResult<Vec<Uuid>> {
|
|
||||||
let pattern = match thread_id {
|
|
||||||
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:*"),
|
|
||||||
None => format!("{TYPING_PREFIX}{channel_id}:*"),
|
|
||||||
};
|
|
||||||
let mut conn = redis.get_connection()?;
|
|
||||||
let keys: Vec<String> = Cmd::new()
|
|
||||||
.arg("KEYS")
|
|
||||||
.arg(&pattern)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
let mut ids = Vec::with_capacity(keys.len());
|
|
||||||
for key in &keys {
|
|
||||||
if let Some(part) = key.rsplit(':').next()
|
|
||||||
&& let Ok(uid) = part.parse::<Uuid>()
|
|
||||||
{
|
|
||||||
ids.push(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn typing_key(channel_id: Uuid, thread_id: Option<Uuid>, user_id: Uuid) -> String {
|
|
||||||
match thread_id {
|
|
||||||
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"),
|
|
||||||
None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,259 @@ pub mod cache;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod etcd;
|
pub mod etcd;
|
||||||
pub mod immediate;
|
pub mod grpc;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod pb;
|
pub mod pb;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use actix_web::cookie::Key;
|
||||||
|
use actix_web::{App, HttpResponse, HttpServer, web};
|
||||||
|
use sqlx::Executor;
|
||||||
|
|
||||||
|
use crate::cache::AppCache;
|
||||||
|
use crate::cache::redis::AppRedis;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::etcd::EtcdRegistry;
|
||||||
|
use crate::models::db::AppDatabase;
|
||||||
|
use crate::queue::NatsQueue;
|
||||||
|
use crate::service::AppService;
|
||||||
|
use crate::session::RedisSessionStore;
|
||||||
|
use crate::storage::s3::AppS3Storage;
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
/// A ready-to-run appks HTTP + gRPC server.
|
||||||
|
///
|
||||||
|
/// Use [`AppksServerBuilder`] to construct an instance from configuration.
|
||||||
|
pub struct AppksServer {
|
||||||
|
service: AppService,
|
||||||
|
db: AppDatabase,
|
||||||
|
config: AppConfig,
|
||||||
|
redis: AppRedis,
|
||||||
|
rpc_addr: SocketAddr,
|
||||||
|
rpc_listener: tokio::net::TcpListener,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder for [`AppksServer`].
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use appks::{AppksServer, config::AppConfig};
|
||||||
|
///
|
||||||
|
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
/// let config = AppConfig::load();
|
||||||
|
/// let server = AppksServer::builder()
|
||||||
|
/// .config(config)
|
||||||
|
/// .build()
|
||||||
|
/// .await?;
|
||||||
|
/// server.serve().await?;
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub struct AppksServerBuilder {
|
||||||
|
config: Option<AppConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppksServer {
|
||||||
|
/// Create a new builder.
|
||||||
|
pub fn builder() -> AppksServerBuilder {
|
||||||
|
AppksServerBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the inner [`AppService`].
|
||||||
|
///
|
||||||
|
/// Useful when embedding appks in another application without running
|
||||||
|
/// the built-in HTTP/gRPC servers — you can wire routes manually.
|
||||||
|
pub fn service(&self) -> &AppService {
|
||||||
|
&self.service
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the HTTP and gRPC servers and block until shutdown.
|
||||||
|
pub async fn serve(self) -> AppResult<()> {
|
||||||
|
// Spawn gRPC server
|
||||||
|
{
|
||||||
|
let grpc_service = self.service.clone();
|
||||||
|
let rpc_addr = self.rpc_addr;
|
||||||
|
let rpc_listener = self.rpc_listener;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) =
|
||||||
|
crate::grpc::start_grpc_server(rpc_addr, rpc_listener, grpc_service).await
|
||||||
|
{
|
||||||
|
tracing::error!(error = %e, "gRPC server failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background JWT key rotation
|
||||||
|
{
|
||||||
|
let token_service = self.service.internal_auth.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(600));
|
||||||
|
interval.tick().await; // skip first immediate tick
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
match token_service.rotate_if_needed().await {
|
||||||
|
Ok(true) => tracing::info!("signing key rotated"),
|
||||||
|
Ok(false) => tracing::debug!("signing key rotation not needed"),
|
||||||
|
Err(e) => tracing::error!(error = %e, "signing key rotation failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP server
|
||||||
|
let host = self
|
||||||
|
.config
|
||||||
|
.get_env_or::<String>("APP_HTTP_HOST", "0.0.0.0".to_string())?;
|
||||||
|
let port = self
|
||||||
|
.config
|
||||||
|
.get_env_or::<u16>("APP_HTTP_PORT", 8000)?;
|
||||||
|
let workers = self.config.get_env_or::<usize>(
|
||||||
|
"APP_HTTP_WORKERS",
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1),
|
||||||
|
)?;
|
||||||
|
let bind_addr = format!("{host}:{port}");
|
||||||
|
let session_key = build_session_key(&self.config)?;
|
||||||
|
let session_cfg = self.config.session_config()?;
|
||||||
|
let redis = self.redis.clone();
|
||||||
|
let service = self.service.clone();
|
||||||
|
|
||||||
|
tracing::info!(addr = %bind_addr, workers, "http server listening");
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
let session_store = RedisSessionStore::new(redis.clone());
|
||||||
|
let session_middleware =
|
||||||
|
session_cfg.build_middleware(session_store, session_key.clone());
|
||||||
|
|
||||||
|
App::new()
|
||||||
|
.wrap(actix_web::middleware::Logger::default())
|
||||||
|
.app_data(web::Data::new(service.clone()))
|
||||||
|
.wrap(session_middleware)
|
||||||
|
.route("/healthz", web::get().to(healthz))
|
||||||
|
.route("/readyz", web::get().to(readyz))
|
||||||
|
.route("/openapi.json", web::get().to(openapi_json))
|
||||||
|
.configure(crate::api::routes::init_routes)
|
||||||
|
})
|
||||||
|
.workers(workers)
|
||||||
|
.bind(bind_addr)?
|
||||||
|
.run()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.db.close().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppksServerBuilder {
|
||||||
|
/// Set the server configuration.
|
||||||
|
pub fn config(mut self, config: AppConfig) -> Self {
|
||||||
|
self.config = Some(config);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the server, connecting to all infrastructure (DB, Redis, S3, etcd, NATS)
|
||||||
|
/// and creating the service layer.
|
||||||
|
pub async fn build(self) -> AppResult<AppksServer> {
|
||||||
|
let config = self.config.unwrap_or_else(AppConfig::load);
|
||||||
|
validate_session_secret(&config)?;
|
||||||
|
|
||||||
|
tracing::info!("starting AppKS");
|
||||||
|
|
||||||
|
let db = AppDatabase::from_config(&config).await?;
|
||||||
|
db.writer().execute("SELECT 1").await?;
|
||||||
|
sqlx::migrate!("./migrate")
|
||||||
|
.run(db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Config(format!("database migration failed: {e}")))?;
|
||||||
|
|
||||||
|
let redis = AppRedis::from_config(&config).await?;
|
||||||
|
let cache = Arc::new(AppCache::from_config(&config).await?);
|
||||||
|
let storage = AppS3Storage::from_config(&config).await?;
|
||||||
|
|
||||||
|
let rpc_host = config.get_env_or::<String>("APP_RPC_SELF_HOST", "0.0.0.0".to_string())?;
|
||||||
|
let rpc_port = config.get_env_or::<u16>("APP_RPC_SELF_PORT", 50050)?;
|
||||||
|
let rpc_addr: SocketAddr = format!("{rpc_host}:{rpc_port}")
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| AppError::Config(format!("invalid gRPC address: {e}")))?;
|
||||||
|
|
||||||
|
let rpc_listener = tokio::net::TcpListener::bind(rpc_addr).await.map_err(|e| {
|
||||||
|
AppError::Config(format!("gRPC bind failed on {rpc_addr}: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let registry = Arc::new(EtcdRegistry::connect(&config).await?);
|
||||||
|
registry.start_discovery().await?;
|
||||||
|
registry
|
||||||
|
.register_self(&config.rpc_self_service_name()?)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let nats = Arc::new(NatsQueue::connect(&config).await?);
|
||||||
|
|
||||||
|
let service = AppService::new(
|
||||||
|
env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
db.clone(),
|
||||||
|
redis.clone(),
|
||||||
|
cache,
|
||||||
|
config.clone(),
|
||||||
|
storage,
|
||||||
|
registry,
|
||||||
|
nats,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(AppksServer {
|
||||||
|
service,
|
||||||
|
db,
|
||||||
|
config,
|
||||||
|
redis,
|
||||||
|
rpc_addr,
|
||||||
|
rpc_listener,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppksServerBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { config: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn healthz() -> HttpResponse {
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({ "status": "ok" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn readyz(service: web::Data<AppService>) -> Result<HttpResponse, AppError> {
|
||||||
|
service.ctx.db.writer().execute("SELECT 1").await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "ready" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn openapi_json() -> HttpResponse {
|
||||||
|
HttpResponse::Ok().json(crate::api::openapi::OpenApiDoc::openapi())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_session_key(config: &AppConfig) -> AppResult<Key> {
|
||||||
|
let secret = session_secret(config)?;
|
||||||
|
Ok(Key::derive_from(secret.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_session_secret(config: &AppConfig) -> AppResult<()> {
|
||||||
|
session_secret(config).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_secret(config: &AppConfig) -> AppResult<String> {
|
||||||
|
let secret = config
|
||||||
|
.env
|
||||||
|
.get("APP_SESSION_SECRET")
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| s.len() >= 32)
|
||||||
|
.ok_or_else(|| AppError::Config("APP_SESSION_SECRET must be at least 32 bytes".into()))?;
|
||||||
|
Ok(secret.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,138 +1,16 @@
|
|||||||
use std::sync::Arc;
|
use appks::{AppksServer, config::AppConfig};
|
||||||
|
|
||||||
use actix_web::cookie::Key;
|
#[tokio::main]
|
||||||
use actix_web::{App, HttpResponse, HttpServer, web};
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use appks::api::openapi::OpenApiDoc;
|
|
||||||
use appks::api::routes::init_routes;
|
|
||||||
use appks::cache::AppCache;
|
|
||||||
use appks::cache::redis::AppRedis;
|
|
||||||
use appks::config::AppConfig;
|
|
||||||
use appks::error::{AppError, AppResult};
|
|
||||||
use appks::etcd::EtcdRegistry;
|
|
||||||
use appks::models::db::AppDatabase;
|
|
||||||
use appks::queue::NatsQueue;
|
|
||||||
use appks::service::AppService;
|
|
||||||
use appks::session::RedisSessionStore;
|
|
||||||
use appks::storage::s3::AppS3Storage;
|
|
||||||
use sqlx::Executor;
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
|
|
||||||
#[actix_web::main]
|
|
||||||
async fn main() -> AppResult<()> {
|
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let config = AppConfig::load();
|
let config = AppConfig::load();
|
||||||
validate_session_secret(&config)?;
|
|
||||||
|
|
||||||
tracing::info!("starting AppKS");
|
let server = AppksServer::builder()
|
||||||
|
.config(config)
|
||||||
let db = AppDatabase::from_config(&config).await?;
|
.build()
|
||||||
db.writer().execute("SELECT 1").await?;
|
|
||||||
sqlx::migrate!("./migrate")
|
|
||||||
.run(db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Config(format!("database migration failed: {e}")))?;
|
|
||||||
|
|
||||||
let redis = AppRedis::from_config(&config).await?;
|
|
||||||
let cache = Arc::new(AppCache::from_config(&config).await?);
|
|
||||||
let storage = AppS3Storage::from_config(&config).await?;
|
|
||||||
|
|
||||||
let registry = Arc::new(EtcdRegistry::connect(&config).await?);
|
|
||||||
registry.start_discovery().await?;
|
|
||||||
if config.get_env_or("APP_ETCD_REGISTER_SELF", false)? {
|
|
||||||
registry
|
|
||||||
.register_self(&config.rpc_self_service_name()?)
|
|
||||||
.await?;
|
.await?;
|
||||||
}
|
server.serve().await?;
|
||||||
|
|
||||||
let nats = Arc::new(NatsQueue::connect(&config).await?);
|
|
||||||
|
|
||||||
let service = AppService::new(
|
|
||||||
env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
db.clone(),
|
|
||||||
redis.clone(),
|
|
||||||
cache,
|
|
||||||
config.clone(),
|
|
||||||
storage,
|
|
||||||
registry,
|
|
||||||
nats,
|
|
||||||
);
|
|
||||||
|
|
||||||
let rpc_host = config.get_env_or::<String>("APP_RPC_SELF_HOST", "0.0.0.0".to_string())?;
|
|
||||||
let rpc_port = config.get_env_or::<u16>("APP_RPC_SELF_PORT", 50050)?;
|
|
||||||
let rpc_addr: std::net::SocketAddr = format!("{rpc_host}:{rpc_port}").parse()
|
|
||||||
.map_err(|e| appks::error::AppError::Config(format!("invalid gRPC address: {e}")))?;
|
|
||||||
let grpc_service = service.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = appks::grpc::start_grpc_server(rpc_addr, grpc_service).await {
|
|
||||||
tracing::error!(error = %e, "gRPC server failed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let host = config.get_env_or::<String>("APP_HTTP_HOST", "0.0.0.0".to_string())?;
|
|
||||||
let port = config.get_env_or::<u16>("APP_HTTP_PORT", 8000)?;
|
|
||||||
let workers = config.get_env_or::<usize>(
|
|
||||||
"APP_HTTP_WORKERS",
|
|
||||||
std::thread::available_parallelism()
|
|
||||||
.map(|n| n.get())
|
|
||||||
.unwrap_or(1),
|
|
||||||
)?;
|
|
||||||
let bind_addr = format!("{host}:{port}");
|
|
||||||
let session_key = build_session_key(&config)?;
|
|
||||||
let session_cfg = config.session_config()?;
|
|
||||||
|
|
||||||
tracing::info!(addr = %bind_addr, workers, "http server listening");
|
|
||||||
|
|
||||||
HttpServer::new(move || {
|
|
||||||
let session_store = RedisSessionStore::new(redis.clone());
|
|
||||||
let session_middleware = session_cfg.build_middleware(session_store, session_key.clone());
|
|
||||||
|
|
||||||
App::new()
|
|
||||||
.wrap(actix_web::middleware::Logger::default())
|
|
||||||
.app_data(web::Data::new(service.clone()))
|
|
||||||
.wrap(session_middleware)
|
|
||||||
.route("/healthz", web::get().to(healthz))
|
|
||||||
.route("/readyz", web::get().to(readyz))
|
|
||||||
.route("/openapi.json", web::get().to(openapi_json))
|
|
||||||
.configure(init_routes)
|
|
||||||
})
|
|
||||||
.workers(workers)
|
|
||||||
.bind(bind_addr)?
|
|
||||||
.run()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
db.close().await;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn healthz() -> HttpResponse {
|
|
||||||
HttpResponse::Ok().json(serde_json::json!({ "status": "ok" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn readyz(service: web::Data<AppService>) -> Result<HttpResponse, AppError> {
|
|
||||||
service.ctx.db.writer().execute("SELECT 1").await?;
|
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "ready" })))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn openapi_json() -> HttpResponse {
|
|
||||||
HttpResponse::Ok().json(OpenApiDoc::openapi())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_session_key(config: &AppConfig) -> AppResult<Key> {
|
|
||||||
let secret = session_secret(config)?;
|
|
||||||
Ok(Key::derive_from(secret.as_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_session_secret(config: &AppConfig) -> AppResult<()> {
|
|
||||||
session_secret(config).map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_secret(config: &AppConfig) -> AppResult<String> {
|
|
||||||
let secret = config
|
|
||||||
.env
|
|
||||||
.get("APP_SESSION_SECRET")
|
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| s.len() >= 32)
|
|
||||||
.ok_or_else(|| AppError::Config("APP_SESSION_SECRET must be at least 32 bytes".into()))?;
|
|
||||||
Ok(secret.to_string())
|
|
||||||
}
|
|
||||||
|
|||||||
+39
-35
@@ -843,10 +843,14 @@ CREATE TABLE IF NOT EXISTS issue (
|
|||||||
deleted_at TIMESTAMPTZ NULL
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_issue_repo_id ON issue (repo_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_issue_author_id ON issue (author_id);
|
CREATE INDEX IF NOT EXISTS idx_issue_author_id ON issue (author_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_issue_repo_created ON issue (repo_id, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_issue_deleted ON issue (deleted_at) WHERE deleted_at IS NOT NULL;
|
CREATE INDEX IF NOT EXISTS idx_issue_deleted ON issue (deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issue' AND column_name = 'repo_id') THEN
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issue_repo_id ON issue (repo_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issue_repo_created ON issue (repo_id, created_at DESC);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- models/issues/issue_labels.rs → issue_label
|
-- models/issues/issue_labels.rs → issue_label
|
||||||
CREATE TABLE IF NOT EXISTS issue_label (
|
CREATE TABLE IF NOT EXISTS issue_label (
|
||||||
@@ -1885,50 +1889,18 @@ CREATE TABLE IF NOT EXISTS notification (
|
|||||||
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
actor_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
actor_id UUID NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
workspace_id UUID NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||||
repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE,
|
|
||||||
issue_id UUID NULL REFERENCES issue(id) ON DELETE CASCADE,
|
|
||||||
pull_request_id UUID NULL REFERENCES pull_request(id) ON DELETE CASCADE,
|
|
||||||
channel_id UUID NULL REFERENCES channel(id) ON DELETE CASCADE,
|
|
||||||
message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
notification_type TEXT NOT NULL,
|
notification_type TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
body TEXT NULL,
|
body TEXT NULL,
|
||||||
target_type TEXT NULL,
|
|
||||||
target_id UUID NULL,
|
|
||||||
action_url TEXT NULL,
|
|
||||||
priority TEXT NOT NULL,
|
|
||||||
read_at TIMESTAMPTZ NULL,
|
read_at TIMESTAMPTZ NULL,
|
||||||
dismissed_at TIMESTAMPTZ NULL,
|
dismissed_at TIMESTAMPTZ NULL,
|
||||||
metadata JSONB NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL,
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
deleted_at TIMESTAMPTZ NULL
|
|
||||||
|
|
||||||
);
|
);
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS issue_id UUID NULL REFERENCES issue(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS pull_request_id UUID NULL REFERENCES pull_request(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS channel_id UUID NULL REFERENCES channel(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS target_type TEXT NULL;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS target_id UUID NULL;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS action_url TEXT NULL;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal';
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS metadata JSONB NULL;
|
|
||||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_user_id ON notification (user_id);
|
CREATE INDEX IF NOT EXISTS idx_notification_user_id ON notification (user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_actor_id ON notification (actor_id);
|
CREATE INDEX IF NOT EXISTS idx_notification_actor_id ON notification (actor_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_workspace_id ON notification (workspace_id);
|
CREATE INDEX IF NOT EXISTS idx_notification_workspace_id ON notification (workspace_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_repo_id ON notification (repo_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_issue_id ON notification (issue_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_pull_request_id ON notification (pull_request_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_channel_id ON notification (channel_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_message_id ON notification (message_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_target_id ON notification (target_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_repo_created ON notification (repo_id, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_ws_created ON notification (workspace_id, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_user_created ON notification (user_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_notification_user_created ON notification (user_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_deleted ON notification (deleted_at) WHERE deleted_at IS NOT NULL;
|
|
||||||
|
|
||||||
-- models/agents/agent_feedback.rs → agent_feedback
|
-- models/agents/agent_feedback.rs → agent_feedback
|
||||||
CREATE TABLE IF NOT EXISTS agent_feedback (
|
CREATE TABLE IF NOT EXISTS agent_feedback (
|
||||||
@@ -2091,28 +2063,60 @@ CREATE INDEX IF NOT EXISTS idx_conversation_summary_to_message_id ON conversatio
|
|||||||
|
|
||||||
-- PHASE B: Deferred FKs (circular / self-referencing)
|
-- PHASE B: Deferred FKs (circular / self-referencing)
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_agent_execution_step_execution_id') THEN
|
||||||
ALTER TABLE agent_execution_step ADD CONSTRAINT fk_agent_execution_step_execution_id
|
ALTER TABLE agent_execution_step ADD CONSTRAINT fk_agent_execution_step_execution_id
|
||||||
FOREIGN KEY (execution_id) REFERENCES agent_execution(id) ON DELETE CASCADE;
|
FOREIGN KEY (execution_id) REFERENCES agent_execution(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_agent_current_version_id') THEN
|
||||||
ALTER TABLE agent ADD CONSTRAINT fk_agent_current_version_id
|
ALTER TABLE agent ADD CONSTRAINT fk_agent_current_version_id
|
||||||
FOREIGN KEY (current_version_id) REFERENCES agent_version(id) ON DELETE CASCADE;
|
FOREIGN KEY (current_version_id) REFERENCES agent_version(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_channel_last_message_id') THEN
|
||||||
ALTER TABLE channel ADD CONSTRAINT fk_channel_last_message_id
|
ALTER TABLE channel ADD CONSTRAINT fk_channel_last_message_id
|
||||||
FOREIGN KEY (last_message_id) REFERENCES message(id) ON DELETE CASCADE;
|
FOREIGN KEY (last_message_id) REFERENCES message(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_message_thread_id') THEN
|
||||||
ALTER TABLE message ADD CONSTRAINT fk_message_thread_id
|
ALTER TABLE message ADD CONSTRAINT fk_message_thread_id
|
||||||
FOREIGN KEY (thread_id) REFERENCES message_thread(id) ON DELETE CASCADE;
|
FOREIGN KEY (thread_id) REFERENCES message_thread(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_message_reply_to_message_id') THEN
|
||||||
ALTER TABLE message ADD CONSTRAINT fk_message_reply_to_message_id
|
ALTER TABLE message ADD CONSTRAINT fk_message_reply_to_message_id
|
||||||
FOREIGN KEY (reply_to_message_id) REFERENCES message(id) ON DELETE CASCADE;
|
FOREIGN KEY (reply_to_message_id) REFERENCES message(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_issue_comment_reply_to_comment_id') THEN
|
||||||
ALTER TABLE issue_comment ADD CONSTRAINT fk_issue_comment_reply_to_comment_id
|
ALTER TABLE issue_comment ADD CONSTRAINT fk_issue_comment_reply_to_comment_id
|
||||||
FOREIGN KEY (reply_to_comment_id) REFERENCES issue_comment(id) ON DELETE CASCADE;
|
FOREIGN KEY (reply_to_comment_id) REFERENCES issue_comment(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_message_thread_root_message_id') THEN
|
||||||
ALTER TABLE message_thread ADD CONSTRAINT fk_message_thread_root_message_id
|
ALTER TABLE message_thread ADD CONSTRAINT fk_message_thread_root_message_id
|
||||||
FOREIGN KEY (root_message_id) REFERENCES message(id) ON DELETE CASCADE;
|
FOREIGN KEY (root_message_id) REFERENCES message(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_conversation_message_parent_message_id') THEN
|
||||||
ALTER TABLE conversation_message ADD CONSTRAINT fk_conversation_message_parent_message_id
|
ALTER TABLE conversation_message ADD CONSTRAINT fk_conversation_message_parent_message_id
|
||||||
FOREIGN KEY (parent_message_id) REFERENCES conversation_message(id) ON DELETE CASCADE;
|
FOREIGN KEY (parent_message_id) REFERENCES conversation_message(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
@@ -27,8 +27,8 @@ CREATE TABLE IF NOT EXISTS wiki_page_revision (
|
|||||||
CONSTRAINT uq_wiki_revision_page_version UNIQUE (page_id, version)
|
CONSTRAINT uq_wiki_revision_page_version UNIQUE (page_id, version)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_wiki_page_repo_id ON wiki_page(repo_id);
|
CREATE INDEX IF NOT EXISTS idx_wiki_page_repo_id ON wiki_page(repo_id);
|
||||||
CREATE INDEX idx_wiki_page_slug ON wiki_page(slug);
|
CREATE INDEX IF NOT EXISTS idx_wiki_page_slug ON wiki_page(slug);
|
||||||
CREATE INDEX idx_wiki_page_deleted_at ON wiki_page(deleted_at) WHERE deleted_at IS NULL;
|
CREATE INDEX IF NOT EXISTS idx_wiki_page_deleted_at ON wiki_page(deleted_at) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_wiki_revision_page_id ON wiki_page_revision(page_id);
|
CREATE INDEX IF NOT EXISTS idx_wiki_revision_page_id ON wiki_page_revision(page_id);
|
||||||
CREATE INDEX idx_wiki_revision_version ON wiki_page_revision(version);
|
CREATE INDEX IF NOT EXISTS idx_wiki_revision_version ON wiki_page_revision(version);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Migration: 013_notification_extra_columns.sql
|
||||||
|
-- Add extended columns to notification table for full feature support.
|
||||||
|
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS repo_id UUID NULL REFERENCES repo(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS issue_id UUID NULL REFERENCES issue(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS pull_request_id UUID NULL REFERENCES pull_request(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS channel_id UUID NULL REFERENCES channel(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS message_id UUID NULL REFERENCES message(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS target_type TEXT NULL;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS target_id UUID NULL;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS action_url TEXT NULL;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal';
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS metadata JSONB NULL;
|
||||||
|
ALTER TABLE notification ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_repo_id ON notification (repo_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_issue_id ON notification (issue_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_pull_request_id ON notification (pull_request_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_channel_id ON notification (channel_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_message_id ON notification (message_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_target_id ON notification (target_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_repo_created ON notification (repo_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_ws_created ON notification (workspace_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_deleted ON notification (deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
//! BaseInfo structs — stable, minimal projections of core entities for API/WS responses.
|
||||||
|
//!
|
||||||
|
//! Every raw UUID foreign key in API / WebSocket responses must be expanded
|
||||||
|
//! to its corresponding `*BaseInfo` object so frontend consumers never
|
||||||
|
//! receive bare identifiers.
|
||||||
|
//!
|
||||||
|
//! Batch resolvers are provided to avoid N+1 queries when enriching
|
||||||
|
//! collections of entities.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::common::{ChannelType, State, Visibility};
|
||||||
|
use crate::models::db::AppDatabase;
|
||||||
|
|
||||||
|
// Section: BaseInfo structs
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct UserBaseInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub is_bot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct WorkspaceBaseInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct RepoBaseInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub workspace_id: Uuid,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub is_fork: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct ChannelBaseInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub channel_type: ChannelType,
|
||||||
|
pub workspace_id: Uuid,
|
||||||
|
pub archived: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct IssueBaseInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub number: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub state: State,
|
||||||
|
pub workspace_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct PullRequestBaseInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub number: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub state: State,
|
||||||
|
pub repo_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct WikiPageBaseInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub repo_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section: DB row structs for batch queries
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct UserBaseInfoRow {
|
||||||
|
id: Uuid,
|
||||||
|
username: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
is_bot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserBaseInfoRow> for UserBaseInfo {
|
||||||
|
fn from(r: UserBaseInfoRow) -> Self {
|
||||||
|
UserBaseInfo {
|
||||||
|
id: r.id,
|
||||||
|
username: r.username,
|
||||||
|
display_name: r.display_name,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
is_bot: r.is_bot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct WorkspaceBaseInfoRow {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
visibility: Visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<WorkspaceBaseInfoRow> for WorkspaceBaseInfo {
|
||||||
|
fn from(r: WorkspaceBaseInfoRow) -> Self {
|
||||||
|
WorkspaceBaseInfo {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
visibility: r.visibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct RepoBaseInfoRow {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
visibility: Visibility,
|
||||||
|
is_fork: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RepoBaseInfoRow> for RepoBaseInfo {
|
||||||
|
fn from(r: RepoBaseInfoRow) -> Self {
|
||||||
|
RepoBaseInfo {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
workspace_id: r.workspace_id,
|
||||||
|
visibility: r.visibility,
|
||||||
|
is_fork: r.is_fork,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct ChannelBaseInfoRow {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
channel_type: ChannelType,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
archived: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ChannelBaseInfoRow> for ChannelBaseInfo {
|
||||||
|
fn from(r: ChannelBaseInfoRow) -> Self {
|
||||||
|
ChannelBaseInfo {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
channel_type: r.channel_type,
|
||||||
|
workspace_id: r.workspace_id,
|
||||||
|
archived: r.archived,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct IssueBaseInfoRow {
|
||||||
|
id: Uuid,
|
||||||
|
number: i64,
|
||||||
|
title: String,
|
||||||
|
state: State,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<IssueBaseInfoRow> for IssueBaseInfo {
|
||||||
|
fn from(r: IssueBaseInfoRow) -> Self {
|
||||||
|
IssueBaseInfo {
|
||||||
|
id: r.id,
|
||||||
|
number: r.number,
|
||||||
|
title: r.title,
|
||||||
|
state: r.state,
|
||||||
|
workspace_id: r.workspace_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct PullRequestBaseInfoRow {
|
||||||
|
id: Uuid,
|
||||||
|
number: i64,
|
||||||
|
title: String,
|
||||||
|
state: State,
|
||||||
|
repo_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PullRequestBaseInfoRow> for PullRequestBaseInfo {
|
||||||
|
fn from(r: PullRequestBaseInfoRow) -> Self {
|
||||||
|
PullRequestBaseInfo {
|
||||||
|
id: r.id,
|
||||||
|
number: r.number,
|
||||||
|
title: r.title,
|
||||||
|
state: r.state,
|
||||||
|
repo_id: r.repo_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserBaseInfo {
|
||||||
|
pub fn placeholder(id: Uuid) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
username: "unknown".into(),
|
||||||
|
display_name: Some("Unknown User".into()),
|
||||||
|
avatar_url: None,
|
||||||
|
is_bot: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section: Batch resolvers
|
||||||
|
|
||||||
|
/// Resolve multiple users to `UserBaseInfo`. Always do a single
|
||||||
|
/// `SELECT … WHERE id = ANY($1)` to avoid N+1.
|
||||||
|
pub async fn resolve_users(
|
||||||
|
db: &AppDatabase,
|
||||||
|
ids: &[Uuid],
|
||||||
|
) -> Result<HashMap<Uuid, UserBaseInfo>, AppError> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, UserBaseInfoRow>(
|
||||||
|
r#"SELECT id, username, display_name, avatar_url, is_bot
|
||||||
|
FROM "user" WHERE id = ANY($1) AND deleted_at IS NULL"#,
|
||||||
|
)
|
||||||
|
.bind(ids)
|
||||||
|
.fetch_all(db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(rows.into_iter().map(|r| (r.id, r.into())).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve multiple workspaces to `WorkspaceBaseInfo`.
|
||||||
|
pub async fn resolve_workspaces(
|
||||||
|
db: &AppDatabase,
|
||||||
|
ids: &[Uuid],
|
||||||
|
) -> Result<HashMap<Uuid, WorkspaceBaseInfo>, AppError> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, WorkspaceBaseInfoRow>(
|
||||||
|
"SELECT id, name, avatar_url, visibility FROM workspace WHERE id = ANY($1) AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(ids)
|
||||||
|
.fetch_all(db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(rows.into_iter().map(|r| (r.id, r.into())).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve multiple repos to `RepoBaseInfo`.
|
||||||
|
pub async fn resolve_repos(
|
||||||
|
db: &AppDatabase,
|
||||||
|
ids: &[Uuid],
|
||||||
|
) -> Result<HashMap<Uuid, RepoBaseInfo>, AppError> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, RepoBaseInfoRow>(
|
||||||
|
"SELECT id, name, workspace_id, visibility, is_fork FROM repo WHERE id = ANY($1) AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(ids)
|
||||||
|
.fetch_all(db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(rows.into_iter().map(|r| (r.id, r.into())).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve multiple channels to `ChannelBaseInfo`.
|
||||||
|
pub async fn resolve_channels(
|
||||||
|
db: &AppDatabase,
|
||||||
|
ids: &[Uuid],
|
||||||
|
) -> Result<HashMap<Uuid, ChannelBaseInfo>, AppError> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, ChannelBaseInfoRow>(
|
||||||
|
"SELECT id, name, channel_type, workspace_id, archived FROM channel WHERE id = ANY($1) AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(ids)
|
||||||
|
.fetch_all(db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(rows.into_iter().map(|r| (r.id, r.into())).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve multiple issues to `IssueBaseInfo`.
|
||||||
|
pub async fn resolve_issues(
|
||||||
|
db: &AppDatabase,
|
||||||
|
ids: &[Uuid],
|
||||||
|
) -> Result<HashMap<Uuid, IssueBaseInfo>, AppError> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, IssueBaseInfoRow>(
|
||||||
|
"SELECT id, number, title, state, workspace_id FROM issue WHERE id = ANY($1) AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(ids)
|
||||||
|
.fetch_all(db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(rows.into_iter().map(|r| (r.id, r.into())).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve multiple pull requests to `PullRequestBaseInfo`.
|
||||||
|
pub async fn resolve_pull_requests(
|
||||||
|
db: &AppDatabase,
|
||||||
|
ids: &[Uuid],
|
||||||
|
) -> Result<HashMap<Uuid, PullRequestBaseInfo>, AppError> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, PullRequestBaseInfoRow>(
|
||||||
|
"SELECT id, number, title, state, repo_id FROM pull_request WHERE id = ANY($1) AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(ids)
|
||||||
|
.fetch_all(db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(rows.into_iter().map(|r| (r.id, r.into())).collect())
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Discussion comment on an article (similar to blog comments).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct ArticleComment {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub article_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub author_id: Uuid,
|
|
||||||
pub parent_comment_id: Option<Uuid>,
|
|
||||||
pub body: String,
|
|
||||||
pub edited_at: Option<DateTime<Utc>>,
|
|
||||||
pub deleted_at: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use crate::models::common::Status;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Tracks a single cross-post delivery when an article is published
|
|
||||||
/// to a follower channel/workspace.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct ArticleCrossPost {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub article_id: Uuid,
|
|
||||||
pub follow_id: Uuid,
|
|
||||||
pub target_workspace_id: Uuid,
|
|
||||||
pub target_channel_id: Option<Uuid>,
|
|
||||||
pub status: Status,
|
|
||||||
pub attempts: i32,
|
|
||||||
pub last_error: Option<String>,
|
|
||||||
pub sent_at: Option<DateTime<Utc>>,
|
|
||||||
pub delivered_at: Option<DateTime<Utc>>,
|
|
||||||
pub failed_at: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Reaction on an article (emoji reactions, separate from message reactions).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct ArticleReaction {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub article_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub content: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
use crate::models::common::{ArticleStatus, JsonValue, Visibility};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Long-form article for announcement/news channels.
|
|
||||||
/// Unlike a plain Message, an article has a title, cover image,
|
|
||||||
/// publish lifecycle, and can be cross-posted to followers.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct Article {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub author_id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
pub slug: String,
|
|
||||||
pub summary: Option<String>,
|
|
||||||
pub body: String,
|
|
||||||
pub cover_image_url: Option<String>,
|
|
||||||
pub status: ArticleStatus,
|
|
||||||
pub visibility: Visibility,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub published_at: Option<DateTime<Utc>>,
|
|
||||||
pub published_by: Option<Uuid>,
|
|
||||||
pub scheduled_at: Option<DateTime<Utc>>,
|
|
||||||
pub unpublished_at: Option<DateTime<Utc>>,
|
|
||||||
pub views_count: i64,
|
|
||||||
pub comments_count: i64,
|
|
||||||
pub reactions_count: i64,
|
|
||||||
pub cross_posted: bool,
|
|
||||||
pub cross_posted_from: Option<Uuid>,
|
|
||||||
pub metadata: Option<JsonValue>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
pub deleted_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::models::base_info::UserBaseInfo;
|
||||||
use crate::models::common::{
|
use crate::models::common::{
|
||||||
ChannelKind, ChannelType, ForumLayout, ForumSortOrder, JsonValue, Visibility,
|
ChannelKind, ChannelType, ForumLayout, ForumSortOrder, JsonValue, Visibility,
|
||||||
};
|
};
|
||||||
@@ -5,7 +6,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
@@ -41,3 +42,64 @@ pub struct Channel {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub deleted_at: Option<DateTime<Utc>>,
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct ChannelDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub workspace_id: Uuid,
|
||||||
|
pub repo_id: Option<Uuid>,
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
pub creator: UserBaseInfo,
|
||||||
|
pub name: String,
|
||||||
|
pub topic: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub channel_type: ChannelType,
|
||||||
|
pub channel_kind: ChannelKind,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub position: Option<i32>,
|
||||||
|
pub nsfw: bool,
|
||||||
|
pub archived: bool,
|
||||||
|
pub read_only: bool,
|
||||||
|
pub bitrate: Option<i32>,
|
||||||
|
pub user_limit: Option<i32>,
|
||||||
|
pub rtc_region: Option<String>,
|
||||||
|
pub parent_channel_id: Option<Uuid>,
|
||||||
|
pub last_message_id: Option<Uuid>,
|
||||||
|
pub last_message_at: Option<DateTime<Utc>>,
|
||||||
|
pub archived_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Channel {
|
||||||
|
pub fn into_detail(self, creator: UserBaseInfo) -> ChannelDetail {
|
||||||
|
ChannelDetail {
|
||||||
|
id: self.id,
|
||||||
|
workspace_id: self.workspace_id,
|
||||||
|
repo_id: self.repo_id,
|
||||||
|
category_id: self.category_id,
|
||||||
|
creator,
|
||||||
|
name: self.name,
|
||||||
|
topic: self.topic,
|
||||||
|
description: self.description,
|
||||||
|
channel_type: self.channel_type,
|
||||||
|
channel_kind: self.channel_kind,
|
||||||
|
visibility: self.visibility,
|
||||||
|
position: self.position,
|
||||||
|
nsfw: self.nsfw,
|
||||||
|
archived: self.archived,
|
||||||
|
read_only: self.read_only,
|
||||||
|
bitrate: self.bitrate,
|
||||||
|
user_limit: self.user_limit,
|
||||||
|
rtc_region: self.rtc_region,
|
||||||
|
parent_channel_id: self.parent_channel_id,
|
||||||
|
last_message_id: self.last_message_id,
|
||||||
|
last_message_at: self.last_message_at,
|
||||||
|
archived_at: self.archived_at,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
deleted_at: self.deleted_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
pub struct ChannelCategory {
|
pub struct ChannelCategory {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Follow relationship on an announcement channel.
|
|
||||||
/// Allows another workspace or channel to receive cross-posts
|
|
||||||
/// when articles are published.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct ChannelFollow {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub source_channel_id: Uuid,
|
|
||||||
pub target_workspace_id: Uuid,
|
|
||||||
pub target_channel_id: Option<Uuid>,
|
|
||||||
pub webhook_url: Option<String>,
|
|
||||||
pub webhook_secret_ciphertext: Option<String>,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub followed_by: Uuid,
|
|
||||||
pub unfollowed_at: Option<DateTime<Utc>>,
|
|
||||||
pub last_delivery_at: Option<DateTime<Utc>>,
|
|
||||||
pub last_delivery_status: Option<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
pub struct ChannelMember {
|
pub struct ChannelMember {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::models::common::{OverwriteTarget, Permission};
|
use crate::models::common::OverwriteTarget;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -9,8 +9,8 @@ pub struct ChannelPermissionOverwrite {
|
|||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
pub target_type: OverwriteTarget,
|
pub target_type: OverwriteTarget,
|
||||||
pub target_id: Uuid,
|
pub target_id: Uuid,
|
||||||
pub allow: Vec<Permission>,
|
pub allow: Vec<String>,
|
||||||
pub deny: Vec<Permission>,
|
pub deny: Vec<String>,
|
||||||
pub created_by: Uuid,
|
pub created_by: Uuid,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
use crate::models::common::{JsonValue, MessageType};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct Message {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub author_id: Uuid,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub reply_to_message_id: Option<Uuid>,
|
|
||||||
pub seq: i64,
|
|
||||||
pub message_type: MessageType,
|
|
||||||
pub body: String,
|
|
||||||
pub metadata: Option<JsonValue>,
|
|
||||||
pub pinned: bool,
|
|
||||||
pub system: bool,
|
|
||||||
pub edited_at: Option<DateTime<Utc>>,
|
|
||||||
pub deleted_at: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageAttachment {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub filename: String,
|
|
||||||
pub url: String,
|
|
||||||
pub proxy_url: Option<String>,
|
|
||||||
pub size_bytes: i64,
|
|
||||||
pub mime_type: String,
|
|
||||||
pub width: Option<i32>,
|
|
||||||
pub height: Option<i32>,
|
|
||||||
pub duration_ms: Option<i64>,
|
|
||||||
pub thumbnail_url: Option<String>,
|
|
||||||
pub blurhash: Option<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageBookmark {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub note: Option<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
use crate::models::common::JsonValue;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageDraft {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub reply_to_message_id: Option<Uuid>,
|
|
||||||
pub content: String,
|
|
||||||
pub attachments: Option<JsonValue>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageEditHistory {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub previous_body: String,
|
|
||||||
pub edited_by: Uuid,
|
|
||||||
pub edited_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
use crate::models::common::{EmbedType, JsonValue};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageEmbed {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub embed_type: EmbedType,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub url: Option<String>,
|
|
||||||
pub author_name: Option<String>,
|
|
||||||
pub author_url: Option<String>,
|
|
||||||
pub author_icon_url: Option<String>,
|
|
||||||
pub thumbnail_url: Option<String>,
|
|
||||||
pub thumbnail_width: Option<i32>,
|
|
||||||
pub thumbnail_height: Option<i32>,
|
|
||||||
pub image_url: Option<String>,
|
|
||||||
pub image_width: Option<i32>,
|
|
||||||
pub image_height: Option<i32>,
|
|
||||||
pub video_url: Option<String>,
|
|
||||||
pub video_width: Option<i32>,
|
|
||||||
pub video_height: Option<i32>,
|
|
||||||
pub color: Option<i32>,
|
|
||||||
pub fields: Option<JsonValue>,
|
|
||||||
pub footer_text: Option<String>,
|
|
||||||
pub footer_icon_url: Option<String>,
|
|
||||||
pub provider_name: Option<String>,
|
|
||||||
pub provider_url: Option<String>,
|
|
||||||
pub timestamp: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageMention {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub mentioned_user_id: Uuid,
|
|
||||||
pub mentioned_by: Uuid,
|
|
||||||
pub read_at: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessagePin {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub pinned_by: Uuid,
|
|
||||||
pub pinned_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Poll option.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessagePollOption {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub poll_id: Uuid,
|
|
||||||
pub position: i32,
|
|
||||||
pub text: String,
|
|
||||||
pub emoji_id: Option<String>,
|
|
||||||
pub emoji_name: Option<String>,
|
|
||||||
pub vote_count: i64,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// User vote record for a poll option.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessagePollVote {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub poll_id: Uuid,
|
|
||||||
pub option_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub voted_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use crate::models::common::{JsonValue, PollLayout};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Message poll (similar to Discord Polls).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessagePoll {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub question: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub layout: PollLayout,
|
|
||||||
pub allow_multiselect: bool,
|
|
||||||
pub duration_hours: Option<i32>,
|
|
||||||
pub ends_at: Option<DateTime<Utc>>,
|
|
||||||
pub total_votes: i64,
|
|
||||||
pub metadata: Option<JsonValue>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageReaction {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub content: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct MessageThread {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub root_message_id: Uuid,
|
|
||||||
pub created_by: Uuid,
|
|
||||||
pub replies_count: i64,
|
|
||||||
pub participants_count: i64,
|
|
||||||
pub last_reply_message_id: Option<Uuid>,
|
|
||||||
pub last_reply_at: Option<DateTime<Utc>>,
|
|
||||||
pub resolved: bool,
|
|
||||||
pub resolved_by: Option<Uuid>,
|
|
||||||
pub resolved_at: Option<DateTime<Utc>>,
|
|
||||||
// ── Forum post specific ──
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub pinned: bool,
|
|
||||||
pub locked: bool,
|
|
||||||
pub rate_limit_per_user: Option<i32>,
|
|
||||||
pub auto_archive_at: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
+1
-41
@@ -1,11 +1,6 @@
|
|||||||
pub mod article_comments;
|
|
||||||
pub mod article_cross_posts;
|
|
||||||
pub mod article_reactions;
|
|
||||||
pub mod articles;
|
|
||||||
pub mod channel;
|
pub mod channel;
|
||||||
pub mod channel_categories;
|
pub mod channel_categories;
|
||||||
pub mod channel_events;
|
pub mod channel_events;
|
||||||
pub mod channel_follows;
|
|
||||||
pub mod channel_invitations;
|
pub mod channel_invitations;
|
||||||
pub mod channel_member_roles;
|
pub mod channel_member_roles;
|
||||||
pub mod channel_members;
|
pub mod channel_members;
|
||||||
@@ -17,32 +12,12 @@ pub mod channel_webhooks;
|
|||||||
pub mod custom_emojis;
|
pub mod custom_emojis;
|
||||||
pub mod forum_tags;
|
pub mod forum_tags;
|
||||||
pub mod im_integrations;
|
pub mod im_integrations;
|
||||||
pub mod message;
|
|
||||||
pub mod message_attachments;
|
|
||||||
pub mod message_bookmarks;
|
|
||||||
pub mod message_drafts;
|
|
||||||
pub mod message_edit_history;
|
|
||||||
pub mod message_embeds;
|
|
||||||
pub mod message_mentions;
|
|
||||||
pub mod message_pins;
|
|
||||||
pub mod message_poll_options;
|
|
||||||
pub mod message_poll_votes;
|
|
||||||
pub mod message_polls;
|
|
||||||
pub mod message_reactions;
|
|
||||||
pub mod message_threads;
|
|
||||||
pub mod saved_messages;
|
|
||||||
pub mod stages;
|
pub mod stages;
|
||||||
pub mod thread_read_states;
|
|
||||||
pub mod voice_participants;
|
pub mod voice_participants;
|
||||||
|
|
||||||
pub use article_comments::ArticleComment;
|
pub use channel::{Channel, ChannelDetail};
|
||||||
pub use article_cross_posts::ArticleCrossPost;
|
|
||||||
pub use article_reactions::ArticleReaction;
|
|
||||||
pub use articles::Article;
|
|
||||||
pub use channel::Channel;
|
|
||||||
pub use channel_categories::ChannelCategory;
|
pub use channel_categories::ChannelCategory;
|
||||||
pub use channel_events::ChannelEvent;
|
pub use channel_events::ChannelEvent;
|
||||||
pub use channel_follows::ChannelFollow;
|
|
||||||
pub use channel_invitations::ChannelInvitation;
|
pub use channel_invitations::ChannelInvitation;
|
||||||
pub use channel_member_roles::ChannelMemberRole;
|
pub use channel_member_roles::ChannelMemberRole;
|
||||||
pub use channel_members::ChannelMember;
|
pub use channel_members::ChannelMember;
|
||||||
@@ -54,20 +29,5 @@ pub use channel_webhooks::ChannelWebhook;
|
|||||||
pub use custom_emojis::CustomEmoji;
|
pub use custom_emojis::CustomEmoji;
|
||||||
pub use forum_tags::ForumTag;
|
pub use forum_tags::ForumTag;
|
||||||
pub use im_integrations::ImIntegration;
|
pub use im_integrations::ImIntegration;
|
||||||
pub use message::Message;
|
|
||||||
pub use message_attachments::MessageAttachment;
|
|
||||||
pub use message_bookmarks::MessageBookmark;
|
|
||||||
pub use message_drafts::MessageDraft;
|
|
||||||
pub use message_edit_history::MessageEditHistory;
|
|
||||||
pub use message_embeds::MessageEmbed;
|
|
||||||
pub use message_mentions::MessageMention;
|
|
||||||
pub use message_pins::MessagePin;
|
|
||||||
pub use message_poll_options::MessagePollOption;
|
|
||||||
pub use message_poll_votes::MessagePollVote;
|
|
||||||
pub use message_polls::MessagePoll;
|
|
||||||
pub use message_reactions::MessageReaction;
|
|
||||||
pub use message_threads::MessageThread;
|
|
||||||
pub use saved_messages::SavedMessage;
|
|
||||||
pub use stages::Stage;
|
pub use stages::Stage;
|
||||||
pub use thread_read_states::ThreadReadState;
|
|
||||||
pub use voice_participants::VoiceParticipant;
|
pub use voice_participants::VoiceParticipant;
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct SavedMessage {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub message_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub note: Option<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct ThreadReadState {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub thread_id: Uuid,
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub last_read_message_id: Option<Uuid>,
|
|
||||||
pub last_read_at: Option<DateTime<Utc>>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::models::base_info::UserBaseInfo;
|
||||||
use crate::models::common::{Priority, State, Visibility};
|
use crate::models::common::{Priority, State, Visibility};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -23,3 +24,48 @@ pub struct Issue {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub deleted_at: Option<DateTime<Utc>>,
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct IssueDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub workspace_id: Uuid,
|
||||||
|
pub author: UserBaseInfo,
|
||||||
|
pub number: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub state: State,
|
||||||
|
pub priority: Priority,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub locked: bool,
|
||||||
|
pub milestone_id: Option<Uuid>,
|
||||||
|
pub closed_by: Option<Uuid>,
|
||||||
|
pub closed_at: Option<DateTime<Utc>>,
|
||||||
|
pub due_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Issue {
|
||||||
|
pub fn into_detail(self, author: UserBaseInfo) -> IssueDetail {
|
||||||
|
IssueDetail {
|
||||||
|
id: self.id,
|
||||||
|
workspace_id: self.workspace_id,
|
||||||
|
author,
|
||||||
|
number: self.number,
|
||||||
|
title: self.title,
|
||||||
|
body: self.body,
|
||||||
|
state: self.state,
|
||||||
|
priority: self.priority,
|
||||||
|
visibility: self.visibility,
|
||||||
|
locked: self.locked,
|
||||||
|
milestone_id: self.milestone_id,
|
||||||
|
closed_by: self.closed_by,
|
||||||
|
closed_at: self.closed_at,
|
||||||
|
due_at: self.due_at,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
deleted_at: self.deleted_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::models::base_info::UserBaseInfo;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -14,3 +15,30 @@ pub struct IssueComment {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct IssueCommentDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub issue_id: Uuid,
|
||||||
|
pub author: UserBaseInfo,
|
||||||
|
pub reply_to_comment_id: Option<Uuid>,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IssueComment {
|
||||||
|
pub fn into_detail(self, author: UserBaseInfo) -> IssueCommentDetail {
|
||||||
|
IssueCommentDetail {
|
||||||
|
id: self.id,
|
||||||
|
issue_id: self.issue_id,
|
||||||
|
author,
|
||||||
|
reply_to_comment_id: self.reply_to_comment_id,
|
||||||
|
body: self.body,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
deleted_at: self.deleted_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ pub mod issue_stats;
|
|||||||
pub mod issue_subscribers;
|
pub mod issue_subscribers;
|
||||||
pub mod issue_templates;
|
pub mod issue_templates;
|
||||||
|
|
||||||
pub use issue::Issue;
|
pub use issue::{Issue, IssueDetail};
|
||||||
pub use issue_assignees::IssueAssignee;
|
pub use issue_assignees::IssueAssignee;
|
||||||
pub use issue_comments::IssueComment;
|
pub use issue_comments::{IssueComment, IssueCommentDetail};
|
||||||
pub use issue_commit_relations::IssueCommitRelation;
|
pub use issue_commit_relations::IssueCommitRelation;
|
||||||
pub use issue_events::IssueEvent;
|
pub use issue_events::IssueEvent;
|
||||||
pub use issue_label_relations::IssueLabelRelation;
|
pub use issue_label_relations::IssueLabelRelation;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod agents;
|
pub mod agents;
|
||||||
pub mod ais;
|
pub mod ais;
|
||||||
|
pub mod base_info;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod conversations;
|
pub mod conversations;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ pub mod notification_deliveries;
|
|||||||
pub mod notification_subscriptions;
|
pub mod notification_subscriptions;
|
||||||
pub mod notification_templates;
|
pub mod notification_templates;
|
||||||
|
|
||||||
pub use notification::Notification;
|
pub use notification::{Notification, NotificationDetail};
|
||||||
pub use notification_blocks::NotificationBlock;
|
pub use notification_blocks::NotificationBlock;
|
||||||
pub use notification_deliveries::NotificationDelivery;
|
pub use notification_deliveries::NotificationDelivery;
|
||||||
pub use notification_subscriptions::NotificationSubscription;
|
pub use notification_subscriptions::NotificationSubscription;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
use crate::models::base_info::{RepoBaseInfo, UserBaseInfo, WorkspaceBaseInfo};
|
||||||
use crate::models::common::{NotificationType, Priority, TargetType};
|
use crate::models::common::{NotificationType, Priority, TargetType};
|
||||||
use crate::models::json_types::{NotificationMetadata, TypedJson};
|
use crate::models::json_types::{NotificationMetadata, TypedJson};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
pub struct Notification {
|
pub struct Notification {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
@@ -24,8 +25,70 @@ pub struct Notification {
|
|||||||
pub priority: Priority,
|
pub priority: Priority,
|
||||||
pub read_at: Option<DateTime<Utc>>,
|
pub read_at: Option<DateTime<Utc>>,
|
||||||
pub dismissed_at: Option<DateTime<Utc>>,
|
pub dismissed_at: Option<DateTime<Utc>>,
|
||||||
|
#[schema(value_type = serde_json::Value)]
|
||||||
pub metadata: Option<TypedJson<NotificationMetadata>>,
|
pub metadata: Option<TypedJson<NotificationMetadata>>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub deleted_at: Option<DateTime<Utc>>,
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct NotificationDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub actor: Option<UserBaseInfo>,
|
||||||
|
pub workspace: Option<WorkspaceBaseInfo>,
|
||||||
|
pub repo: Option<RepoBaseInfo>,
|
||||||
|
pub issue_id: Option<Uuid>,
|
||||||
|
pub pull_request_id: Option<Uuid>,
|
||||||
|
pub channel_id: Option<Uuid>,
|
||||||
|
pub message_id: Option<Uuid>,
|
||||||
|
pub notification_type: NotificationType,
|
||||||
|
pub title: String,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub target_type: Option<TargetType>,
|
||||||
|
pub target_id: Option<Uuid>,
|
||||||
|
pub action_url: Option<String>,
|
||||||
|
pub priority: Priority,
|
||||||
|
pub read_at: Option<DateTime<Utc>>,
|
||||||
|
pub dismissed_at: Option<DateTime<Utc>>,
|
||||||
|
#[schema(value_type = serde_json::Value)]
|
||||||
|
pub metadata: Option<TypedJson<NotificationMetadata>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notification {
|
||||||
|
pub fn into_detail(
|
||||||
|
self,
|
||||||
|
actor: Option<UserBaseInfo>,
|
||||||
|
workspace: Option<WorkspaceBaseInfo>,
|
||||||
|
repo: Option<RepoBaseInfo>,
|
||||||
|
) -> NotificationDetail {
|
||||||
|
NotificationDetail {
|
||||||
|
id: self.id,
|
||||||
|
user_id: self.user_id,
|
||||||
|
actor,
|
||||||
|
workspace,
|
||||||
|
repo,
|
||||||
|
issue_id: self.issue_id,
|
||||||
|
pull_request_id: self.pull_request_id,
|
||||||
|
channel_id: self.channel_id,
|
||||||
|
message_id: self.message_id,
|
||||||
|
notification_type: self.notification_type,
|
||||||
|
title: self.title,
|
||||||
|
body: self.body,
|
||||||
|
target_type: self.target_type,
|
||||||
|
target_id: self.target_id,
|
||||||
|
action_url: self.action_url,
|
||||||
|
priority: self.priority,
|
||||||
|
read_at: self.read_at,
|
||||||
|
dismissed_at: self.dismissed_at,
|
||||||
|
metadata: self.metadata,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
deleted_at: self.deleted_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
pub struct NotificationBlock {
|
pub struct NotificationBlock {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
pub struct NotificationDelivery {
|
pub struct NotificationDelivery {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub notification_id: Uuid,
|
pub notification_id: Uuid,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
pub struct NotificationSubscription {
|
pub struct NotificationSubscription {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
pub struct NotificationTemplate {
|
pub struct NotificationTemplate {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub key: String,
|
pub key: String,
|
||||||
|
|||||||
+6
-2
@@ -9,8 +9,10 @@ pub mod pr_merge_strategy;
|
|||||||
pub mod pr_reactions;
|
pub mod pr_reactions;
|
||||||
pub mod pr_review;
|
pub mod pr_review;
|
||||||
pub mod pr_review_comment;
|
pub mod pr_review_comment;
|
||||||
|
pub mod pr_review_requests;
|
||||||
pub mod pr_status;
|
pub mod pr_status;
|
||||||
pub mod pr_subscriptions;
|
pub mod pr_subscriptions;
|
||||||
|
pub mod pr_templates;
|
||||||
pub mod pull_request;
|
pub mod pull_request;
|
||||||
pub mod pull_request_queries;
|
pub mod pull_request_queries;
|
||||||
|
|
||||||
@@ -23,8 +25,10 @@ pub use pr_label_relations::PrLabelRelation;
|
|||||||
pub use pr_labels::PrLabel;
|
pub use pr_labels::PrLabel;
|
||||||
pub use pr_merge_strategy::PrMergeStrategy;
|
pub use pr_merge_strategy::PrMergeStrategy;
|
||||||
pub use pr_reactions::PrReaction;
|
pub use pr_reactions::PrReaction;
|
||||||
pub use pr_review::PrReview;
|
pub use pr_review::{PrReview, PrReviewDetail};
|
||||||
pub use pr_review_comment::PrReviewComment;
|
pub use pr_review_comment::PrReviewComment;
|
||||||
|
pub use pr_review_requests::PrReviewRequest;
|
||||||
pub use pr_status::PrStatus;
|
pub use pr_status::PrStatus;
|
||||||
pub use pr_subscriptions::PrSubscription;
|
pub use pr_subscriptions::PrSubscription;
|
||||||
pub use pull_request::PullRequest;
|
pub use pr_templates::PrTemplate;
|
||||||
|
pub use pull_request::{PullRequest, PullRequestDetail};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::models::base_info::UserBaseInfo;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -17,3 +18,34 @@ pub struct PrReview {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct PrReviewDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub pull_request_id: Uuid,
|
||||||
|
pub author: UserBaseInfo,
|
||||||
|
pub state: String,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub dismissed: bool,
|
||||||
|
pub dismissed_by: Option<Uuid>,
|
||||||
|
pub dismissed_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrReview {
|
||||||
|
pub fn into_detail(self, author: UserBaseInfo) -> PrReviewDetail {
|
||||||
|
PrReviewDetail {
|
||||||
|
id: self.id,
|
||||||
|
pull_request_id: self.pull_request_id,
|
||||||
|
author,
|
||||||
|
state: self.state,
|
||||||
|
body: self.body,
|
||||||
|
dismissed: self.dismissed_at.is_some(),
|
||||||
|
dismissed_by: self.dismissed_by,
|
||||||
|
dismissed_at: self.dismissed_at,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
|
pub struct PrReviewRequest {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub pull_request_id: Uuid,
|
||||||
|
pub reviewer_id: Uuid,
|
||||||
|
pub requested_by: Uuid,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
|
||||||
|
pub struct PrTemplate {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub repo_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub title_template: Option<String>,
|
||||||
|
pub body_template: String,
|
||||||
|
pub labels: Vec<String>,
|
||||||
|
pub active: bool,
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::models::base_info::UserBaseInfo;
|
||||||
use crate::models::common::State;
|
use crate::models::common::State;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -29,3 +30,60 @@ pub struct PullRequest {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub deleted_at: Option<DateTime<Utc>>,
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct PullRequestDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub repo_id: Uuid,
|
||||||
|
pub author: UserBaseInfo,
|
||||||
|
pub number: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub state: State,
|
||||||
|
pub source_repo_id: Uuid,
|
||||||
|
pub source_branch: String,
|
||||||
|
pub target_repo_id: Uuid,
|
||||||
|
pub target_branch: String,
|
||||||
|
pub base_commit_sha: Option<String>,
|
||||||
|
pub head_commit_sha: String,
|
||||||
|
pub merge_commit_sha: Option<String>,
|
||||||
|
pub draft: bool,
|
||||||
|
pub locked: bool,
|
||||||
|
pub merged_by: Option<Uuid>,
|
||||||
|
pub merged_at: Option<DateTime<Utc>>,
|
||||||
|
pub closed_by: Option<Uuid>,
|
||||||
|
pub closed_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PullRequest {
|
||||||
|
pub fn into_detail(self, author: UserBaseInfo) -> PullRequestDetail {
|
||||||
|
PullRequestDetail {
|
||||||
|
id: self.id,
|
||||||
|
repo_id: self.repo_id,
|
||||||
|
author,
|
||||||
|
number: self.number,
|
||||||
|
title: self.title,
|
||||||
|
body: self.body,
|
||||||
|
state: self.state,
|
||||||
|
source_repo_id: self.source_repo_id,
|
||||||
|
source_branch: self.source_branch,
|
||||||
|
target_repo_id: self.target_repo_id,
|
||||||
|
target_branch: self.target_branch,
|
||||||
|
base_commit_sha: self.base_commit_sha,
|
||||||
|
head_commit_sha: self.head_commit_sha,
|
||||||
|
merge_commit_sha: self.merge_commit_sha,
|
||||||
|
draft: self.draft,
|
||||||
|
locked: self.locked,
|
||||||
|
merged_by: self.merged_by,
|
||||||
|
merged_at: self.merged_at,
|
||||||
|
closed_by: self.closed_by,
|
||||||
|
closed_at: self.closed_at,
|
||||||
|
created_at: self.created_at,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
deleted_at: self.deleted_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -10,6 +10,7 @@ pub mod repo_members;
|
|||||||
pub mod repo_push_commit;
|
pub mod repo_push_commit;
|
||||||
pub mod repo_push_lock;
|
pub mod repo_push_lock;
|
||||||
pub mod repo_queries;
|
pub mod repo_queries;
|
||||||
|
pub mod repo_release_assets;
|
||||||
pub mod repo_releases;
|
pub mod repo_releases;
|
||||||
pub mod repo_stars;
|
pub mod repo_stars;
|
||||||
pub mod repo_stats;
|
pub mod repo_stats;
|
||||||
@@ -18,7 +19,7 @@ pub mod repo_watches;
|
|||||||
pub mod repo_webhooks;
|
pub mod repo_webhooks;
|
||||||
|
|
||||||
pub use branch_protection_rule::BranchProtectionRule;
|
pub use branch_protection_rule::BranchProtectionRule;
|
||||||
pub use repo::Repo;
|
pub use repo::{Repo, RepoDetail};
|
||||||
pub use repo_branches::RepoBranch;
|
pub use repo_branches::RepoBranch;
|
||||||
pub use repo_commit_comments::RepoCommitComment;
|
pub use repo_commit_comments::RepoCommitComment;
|
||||||
pub use repo_commit_statuses::RepoCommitStatus;
|
pub use repo_commit_statuses::RepoCommitStatus;
|
||||||
@@ -28,6 +29,7 @@ pub use repo_invitations::RepoInvitation;
|
|||||||
pub use repo_members::RepoMember;
|
pub use repo_members::RepoMember;
|
||||||
pub use repo_push_commit::RepoPushCommit;
|
pub use repo_push_commit::RepoPushCommit;
|
||||||
pub use repo_push_lock::RepoPushLock;
|
pub use repo_push_lock::RepoPushLock;
|
||||||
|
pub use repo_release_assets::RepoReleaseAsset;
|
||||||
pub use repo_releases::RepoRelease;
|
pub use repo_releases::RepoRelease;
|
||||||
pub use repo_stars::RepoStar;
|
pub use repo_stars::RepoStar;
|
||||||
pub use repo_stats::RepoStats;
|
pub use repo_stats::RepoStats;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user