Compare commits

..

11 Commits

Author SHA1 Message Date
zhenyi 47ed59c9d6 refactor: extract AppksServer as library, thin main.rs to bootstrap-only
- Add AppksServer / AppksServerBuilder to lib.rs
- Move DB/Redis/Cache/S3/etcd/NATS init into builder
- Move gRPC server spawn, JWT rotation, actix-web HTTP into serve()
- Expose service() getter for embedding without the built-in servers
- main.rs reduced to ~10 lines
2026-06-12 21:37:07 +08:00
zhenyi 931d82cbb9 update mian.rs and openapi.json 2026-06-12 16:55:36 +08:00
zhenyi 5f4e9bdfa7 refactor(grpc): bind TCP listener before etcd registration to prevent connection issues
- Change tokio-stream dependency to include net feature for TcpListenerStream
- Move TCP listener binding before etcd registry initialization in main function
- Pass pre-bound TcpListener to gRPC server instead of just SocketAddr
- Update gRPC server to use serve_with_incoming with TcpListenerStream
- Prevent peers from attempting connections before gRPC server is ready
- Ensure proper error handling for TCP binding failures during startup
2026-06-11 23:07:36 +08:00
zhenyi b797e360c0 feat(registry): add service discovery and health check capabilities
- Integrate tonic-health for gRPC service health monitoring
- Add etcd-based service registration with automatic keep-alive
- Implement dynamic configuration loading from etcd with fallback
- Remove external dependencies from docker-compose for simplified deployment
- Refactor service registration logic with improved lease management
- Add health service to gRPC server with serving status reporting
2026-06-11 22:50:40 +08:00
zhenyi 1ccfd3d626 chore(build): add Docker configuration and update dependency versions
- Update Cargo.toml with shortened version specifications for dependencies
- Add .dockerignore file to exclude unnecessary files from Docker builds
- Create .env.example with comprehensive environment variable configurations
- Add docker-compose.yaml with complete multi-service infrastructure setup
- Add Dockerfile with optimized multi-stage build process using cargo-chef
- Add Dockerfile.fast for faster container builds during development
- Configure
2026-06-11 15:32:18 +08:00
zhenyi dbbfb747a4 feat(auth): replace internal auth with JWT token service
- Replace InternalAuthService with TokenService using JWT tokens
- Add support for token issuance, refresh, verification and revocation
- Implement automatic signing key rotation with Redis storage
- Add database migration checks for indexes and foreign key constraints
- Update gRPC endpoints to use token-based authentication
- Remove deprecated API key based authentication system
- Add JSON Web Token support with HMAC-SHA256 signing
- Implement refresh token handling with automatic rotation
- Add token revocation by JTI and user ID
- Update build configuration to include core proto files
- Migrate database schema to handle token-based authentication
- Add comprehensive token validation and verification logic
2026-06-11 15:08:13 +08:00
zhenyi a0bea36041 fix(db): split notification table DDL to support existing databases
- Remove extended columns (repo_id, issue_id, pull_request_id,
  channel_id, message_id, target_type, target_id, action_url,
  priority, metadata, deleted_at) from CREATE TABLE in 001_init.sql
- Add migration 013_notification_extra_columns.sql that uses
  ALTER TABLE ADD COLUMN IF NOT EXISTS for all extended columns
- Move extended column indexes to the new migration
- Ensures compatibility with databases that already have a basic
  notification table from a previous schema version
2026-06-10 18:53:49 +08:00
zhenyi 63ca1151ae docs: add AGENTS.md development guidelines
- Define code style rules, forbidden patterns, and error handling
- Document security, database, API design, and testing standards
- Include Git workflow and architecture decision records
2026-06-10 18:49:53 +08:00
zhenyi 1000f8a80d chore(infra): add gRPC layer, update protobufs, remove immediate module
- Add gRPC service modules: auth, channel, channel settings, member,
  permission
- Update protobuf definitions and generated code
- Remove immediate/ real-time module (superseded by IM service)
- Update etcd discovery and registration
- Update cache, error, config, and build infrastructure
- Add ADR documentation
- Update OpenAPI spec
2026-06-10 18:49:42 +08:00
zhenyi 9eb77ab98b refactor(models): update data models and remove deprecated IM entities
- Update channel, notification, PR, repo, user, workspace models
- Remove deleted IM models: articles, channel follows, message
  attachments/bookmarks/drafts/edit history/embeds/mentions/pins/
  polls/reactions/threads, saved messages, thread read states
- Add new PR models: review requests, templates
- Add repo release assets model
- Add base_info module for API detail responses
2026-06-10 18:49:37 +08:00
zhenyi 420dedbc1e feat(service): expand service layer with new domain operations
- Add IM service modules: audit, channel roles, custom emojis, forum
  tags, integrations, invitations, repo links, slash commands, stages,
  voice, webhooks
- Add PR service modules: review requests, templates
- Add repo service modules: contributors, release assets, git extras
  (archive, branch rename, commit extras, diff/merge, tag, tree)
- Add user service: social (follow/block)
- Add internal auth service
- Update existing service modules with expanded functionality
- Remove deleted IM modules: articles, delivery trace, drafts,
  follows, messages, polls, presence, reactions, threads
2026-06-10 18:49:32 +08:00
226 changed files with 29297 additions and 7295 deletions
+8
View File
@@ -0,0 +1,8 @@
.codegraph
.claude
target
.git
.idea
*.md
LICENSE
.env
+120
View File
@@ -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
+654
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+16
View File
@@ -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"]
+2
View File
@@ -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),
+57
View File
@@ -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,
})))
}
+30 -37
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+78 -22
View File
@@ -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)
}
+61 -20
View File
@@ -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 {
+45 -60
View File
@@ -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,
} }
} }
} }
+4
View File
@@ -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")
}
} }
+85
View File
@@ -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
+50
View File
@@ -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)
+48
View File
@@ -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/)
+49
View File
@@ -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 → 数据库查询
+45
View File
@@ -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: 可配置的流前缀
+46
View File
@@ -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 客户端
+68
View File
@@ -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 |
+24 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+360
View File
@@ -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 }))
}
}
-200
View File
@@ -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)
}
-58
View File
@@ -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(())
}
}
-39
View File
@@ -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
}
}
}
-447
View File
@@ -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,
}
}
-68
View File
@@ -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>,
},
}
-46
View File
@@ -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)
}
}
-36
View File
@@ -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};
-81
View File
@@ -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}")
}
}
-256
View File
@@ -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,
}
-102
View File
@@ -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
}
}
-101
View File
@@ -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(())
}
}
-19
View File
@@ -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;
-52
View File
@@ -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;
});
}
}
-117
View File
@@ -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(())
}
}
-301
View File
@@ -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, &current)?;
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(&current.user_id)
&& devices.get(&current.device_id).copied() == Some(current.connection_id)
{
devices.remove(&current.device_id);
}
self.user_devices
.remove_if(&current.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
}
}
-93
View File
@@ -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(())
}
-53
View File
@@ -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)
}
}
-71
View File
@@ -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}"),
}
}
+250 -1
View File
@@ -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())
}
+7 -129
View File
@@ -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
View File
@@ -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;
+5 -5
View File
@@ -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;
+330
View File
@@ -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())
}
-18
View File
@@ -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>,
}
-22
View File
@@ -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>,
}
-14
View File
@@ -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>,
}
-35
View File
@@ -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>>,
}
+63 -1
View File
@@ -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,
}
}
}
+1 -1
View File
@@ -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,
-23
View File
@@ -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>,
}
+1 -1
View File
@@ -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>,
-23
View File
@@ -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>,
}
-21
View File
@@ -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>,
}
-14
View File
@@ -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>,
}
-17
View File
@@ -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>,
}
-13
View File
@@ -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>,
}
-34
View File
@@ -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>,
}
-14
View File
@@ -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>,
}
-12
View File
@@ -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>,
}
-16
View File
@@ -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>,
}
-13
View File
@@ -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>,
}
-22
View File
@@ -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>,
}
-13
View File
@@ -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>,
}
-27
View File
@@ -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
View File
@@ -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;
-13
View File
@@ -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>,
}
-14
View File
@@ -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>,
}
+46
View File
@@ -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,
}
}
}
+28
View File
@@ -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,
}
}
}
+2 -2
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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;
+64 -1
View File
@@ -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,
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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};
+32
View File
@@ -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,
}
}
}
+11
View File
@@ -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>,
}
+17
View File
@@ -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>,
}
+58
View File
@@ -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
View File
@@ -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