├── .dockerignore ├── .env ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── rust_checks.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .tokeignore ├── CONTRIBUTING.md ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── _typos.toml ├── abi ├── Cargo.toml ├── build.rs ├── protos │ └── messages.proto └── src │ ├── config.rs │ ├── errors │ └── mod.rs │ ├── lib.rs │ ├── pb │ ├── message.rs │ └── mod.rs │ ├── types │ ├── friend.rs │ ├── group.rs │ ├── mod.rs │ ├── msg.rs │ └── user.rs │ └── utils.rs ├── api ├── Cargo.toml ├── fixtures │ ├── templates │ │ └── email_temp.html │ └── xdb │ │ └── ip2region.xdb └── src │ ├── api_utils │ ├── custom_extract │ │ ├── auth.rs │ │ ├── json_extractor.rs │ │ ├── mod.rs │ │ └── path_extractor.rs │ ├── ip_region │ │ └── mod.rs │ ├── lb │ │ ├── mod.rs │ │ └── strategy.rs │ └── mod.rs │ ├── handlers │ ├── files │ │ ├── file.rs │ │ └── mod.rs │ ├── friends │ │ ├── friend_handlers.rs │ │ └── mod.rs │ ├── groups │ │ ├── group_handlers.rs │ │ └── mod.rs │ ├── messages │ │ ├── mod.rs │ │ └── msg_handlers.rs │ ├── mod.rs │ └── users │ │ ├── mod.rs │ │ ├── oauth2.rs │ │ └── user_handlers.rs │ ├── lib.rs │ ├── main.rs │ └── routes.rs ├── cache ├── Cargo.toml └── src │ ├── lib.rs │ └── redis │ └── mod.rs ├── cmd ├── Cargo.toml └── src │ ├── load_seq.rs │ └── main.rs ├── config-docker.yml ├── config.yml ├── db ├── Cargo.toml └── src │ ├── friend.rs │ ├── group.rs │ ├── lib.rs │ ├── main.rs │ ├── message.rs │ ├── mongodb │ ├── message.rs │ ├── mod.rs │ └── utils.rs │ ├── postgres │ ├── friend.rs │ ├── group.rs │ ├── message.rs │ ├── mod.rs │ ├── seq.rs │ └── user.rs │ ├── seq.rs │ └── user.rs ├── deny.toml ├── docker-compose.yml ├── migrations ├── 20240322014338_users.down.sql ├── 20240322014338_users.up.sql ├── 20240322014354_friends.down.sql ├── 20240322014354_friends.up.sql ├── 20240322014403_friendships.down.sql ├── 20240322014403_friendships.up.sql ├── 20240322014412_messages.down.sql ├── 20240322014412_messages.up.sql ├── 20240322014422_groups.down.sql ├── 20240322014422_groups.up.sql ├── 20240322014431_group_members.down.sql ├── 20240322014431_group_members.up.sql ├── 20240602023816_seq.down.sql └── 20240602023816_seq.up.sql ├── msg_gateway ├── Cargo.toml ├── src │ ├── client.rs │ ├── lib.rs │ ├── main.rs │ ├── manager.rs │ ├── rpc.rs │ └── ws_server.rs └── tests │ └── rpc_test.rs ├── msg_server ├── Cargo.toml └── src │ ├── consumer.rs │ ├── lib.rs │ ├── main.rs │ ├── productor.rs │ └── pusher │ ├── mod.rs │ └── service.rs ├── oss ├── Cargo.toml ├── default_avatar │ ├── avatar1.png │ ├── avatar2.png │ └── avatar3.png └── src │ ├── client.rs │ ├── lib.rs │ └── main.rs ├── rfcs ├── core_flow.md ├── draft.md ├── greet.md ├── images │ ├── contacts.jpg │ ├── framework-english.png │ ├── home.jpg │ ├── image-login.png │ ├── phone_call.png │ ├── register.jpg │ ├── seq-chart.png │ ├── 时序图.awebp │ └── 架构图.png ├── readme_cn.md └── template.md └── utils ├── Cargo.toml ├── migrations ├── 20240301073623_todo.down.sql └── 20240301073623_todo.up.sql └── src ├── client_factory.rs ├── lib.rs ├── mongodb_tester └── mod.rs ├── service_discovery ├── mod.rs ├── service_fetcher.rs └── tonic_service_discovery.rs ├── service_register_center ├── consul │ └── mod.rs ├── mod.rs └── typos.rs └── sqlx_tester └── mod.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | .dockerignore 3 | Cargo.lock 4 | .idea 5 | .git 6 | .gitignore 7 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/im 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/rust_checks.yml: -------------------------------------------------------------------------------- 1 | name: Rust Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Setup | Checkout 13 | uses: actions/checkout@v4 14 | 15 | - uses: actions/cache@v4 16 | with: 17 | path: | 18 | ~/.cargo/registry/index/ 19 | ~/.cargo/registry/cache/ 20 | ~/.cargo/git/db/ 21 | target/ 22 | key: ${{ runner.os }}-lint-${{ hashFiles('**/Cargo.lock') }} 23 | 24 | - name: Cache | Protoc 25 | id: cache_protoc 26 | uses: actions/cache@v3 27 | with: 28 | path: /opt/protoc 29 | key: protoc-${{ runner.os }}-23.x 30 | restore-keys: protoc-${{ runner.os }}- 31 | 32 | - name: Install | Protoc 33 | if: steps.cache_protoc.outputs.cache-hit != 'true' 34 | run: | 35 | mkdir -p /opt/protoc 36 | cd /opt/protoc 37 | wget https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip 38 | unzip protoc-23.4-linux-x86_64.zip -d /opt/protoc 39 | 40 | - name: Add Protoc to PATH 41 | run: echo "/opt/protoc/bin" >> $GITHUB_PATH 42 | 43 | - name: Setup | Install rustup 44 | run: | 45 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 46 | source $HOME/.cargo/env 47 | 48 | - name: Setup | Disable rustup self-update 49 | run: | 50 | rustup set auto-self-update disable 51 | 52 | - name: Setup | Toolchain (clippy) 53 | run: | 54 | rustup toolchain install stable --component clippy 55 | rustup default stable 56 | 57 | - name: Build | Clippy 58 | run: | 59 | cargo clippy --all-targets --no-default-features --features=static -- -D warnings 60 | 61 | - name: Build | Rustfmt 62 | run: cargo fmt -- --check 63 | 64 | check: 65 | runs-on: ubuntu-latest 66 | strategy: 67 | matrix: 68 | rust: 69 | - stable 70 | 71 | steps: 72 | - name: Setup | Checkout 73 | uses: actions/checkout@v4 74 | 75 | - name: Setup | Cache Cargo 76 | uses: actions/cache@v4 77 | with: 78 | path: | 79 | ~/.cargo/registry/index/ 80 | ~/.cargo/registry/cache/ 81 | ~/.cargo/git/db/ 82 | target/ 83 | key: ${{ runner.os }}-check-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} 84 | 85 | - name: Cache | Protoc 86 | id: cache_protoc_check 87 | uses: actions/cache@v3 88 | with: 89 | path: /opt/protoc 90 | key: protoc-${{ runner.os }}-23.x 91 | restore-keys: protoc-${{ runner.os }}- 92 | 93 | - name: Install | Protoc 94 | if: steps.cache_protoc_check.outputs.cache-hit != 'true' 95 | run: | 96 | mkdir -p /opt/protoc 97 | cd /opt/protoc 98 | wget https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip 99 | unzip protoc-23.4-linux-x86_64.zip -d /opt/protoc 100 | 101 | - name: Add Protoc to PATH 102 | run: echo "/opt/protoc/bin" >> $GITHUB_PATH 103 | 104 | - name: Set PROTOC environment variable 105 | run: echo "PROTOC=/opt/protoc/bin/protoc" >> $GITHUB_ENV 106 | 107 | - name: Setup | Install rustup 108 | run: | 109 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 110 | source $HOME/.cargo/env 111 | 112 | - name: Setup | Disable rustup self-update 113 | run: | 114 | rustup set auto-self-update disable 115 | 116 | - name: Setup | Rust 117 | run: | 118 | rustup toolchain install ${{ matrix.rust }} 119 | rustup default ${{ matrix.rust }} 120 | 121 | - name: Build | Check 122 | run: cargo check --no-default-features --features=static 123 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | .tokeignore 4 | Cargo.lock 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: false 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v2.3.0 5 | hooks: 6 | - id: check-byte-order-marker 7 | - id: check-case-conflict 8 | - id: check-merge-conflict 9 | - id: check-symlinks 10 | - id: check-yaml 11 | - id: end-of-file-fixer 12 | - id: mixed-line-ending 13 | - id: trailing-whitespace 14 | - repo: https://github.com/psf/black 15 | rev: 19.3b0 16 | hooks: 17 | - id: black 18 | - repo: https://github.com/crate-ci/typos 19 | rev: v1.8.1 20 | hooks: 21 | - id: typos 22 | - repo: local 23 | hooks: 24 | - id: cargo-fmt 25 | name: cargo fmt 26 | description: Format files with rustfmt. 27 | entry: bash -c 'cargo fmt -- --check' 28 | language: rust 29 | files: \.rs$ 30 | args: [] 31 | # - id: cargo-deny 32 | # name: cargo deny check 33 | # description: Check cargo dependencies 34 | # entry: bash -c 'cargo deny check' 35 | # language: rust 36 | # files: \.rs$ 37 | # args: [] 38 | - id: cargo-check 39 | name: cargo check 40 | description: Check the package for errors. 41 | entry: bash -c 'cargo check --all' 42 | language: rust 43 | files: \.rs$ 44 | pass_filenames: false 45 | - id: cargo-clippy 46 | name: cargo clippy 47 | description: Lint rust sources 48 | entry: bash -c 'cargo clippy --all-targets --all-features --tests --benches -- -D warnings' 49 | language: rust 50 | files: \.rs$ 51 | pass_filenames: false 52 | - id: cargo-test 53 | name: cargo test 54 | description: unit test for the project 55 | entry: bash -c 'cargo nextest run --all-features' 56 | language: rust 57 | files: \.rs$ 58 | pass_filenames: false 59 | -------------------------------------------------------------------------------- /.tokeignore: -------------------------------------------------------------------------------- 1 | abi/src/pb 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # contributing 2 | 3 | ============ 4 | We are all living beings, and what is most important is that we respect each other and work together. If you can not uphold this simple standard, then your contributions are not welcome. 5 | 6 | ## hacking 7 | 8 | Just a few simple items to keep in mind as you hack. 9 | 10 | - Pull request early and often. This helps to let others know what you are working on. **Please use GitHub's Draft PR mechanism** if your PR is not yet ready for review. 11 | - Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/), a changelog will automatically be created from such commits 12 | - When making changes to the configuration, be sure to regenate the schema. This can be done by running: 13 | 14 | ```shell 15 | cargo run -- config generate-schema schemas/config.json 16 | ``` 17 | 18 | ## linting 19 | 20 | We are using clippy & rustfmt. Clippy is SO GREAT! Rustfmt ... has a lot more growing to do; however, we are using it for uniformity. 21 | 22 | Please be sure that you've configured your editor to use clippy & rustfmt, or execute them manually before submitting your code. CI will fail your PR if you do not. 23 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "abi", 4 | "api", 5 | "cache", 6 | "msg_server", 7 | "cmd", 8 | "oss", 9 | "utils", 10 | "msg_gateway", 11 | ] 12 | resolver = "2" 13 | default-members = ["cmd"] 14 | 15 | [profile.dev] 16 | incremental = true 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 第一阶段:构建环境 2 | FROM debian:buster-slim AS builder 3 | 4 | # 替换源以加速更新和安装过程 5 | # 设置 rdkafka 和 protobuf 版本 6 | ARG PROTOC_VERSION=26.1 7 | ARG LIBRDKAFKA_VERSION=2.4.0 8 | 9 | # 安装 protoc 编译器和必要的包 10 | RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list && \ 11 | apt-get update && \ 12 | apt-get install -y curl gcc g++ make pkg-config libssl-dev cmake unzip && \ 13 | apt-get clean && rm -rf /var/lib/apt/lists/* 14 | # 安装 protoc 编译器https://github.com/protocolbuffers/protobuf/releases/download/v27.1/protoc-27.1-linux-x86_64.zip 15 | RUN curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOC_VERSION/protoc-$PROTOC_VERSION-linux-x86_64.zip && \ 16 | unzip -o protoc-$PROTOC_VERSION-linux-x86_64.zip -d /usr/local/bin/protoc && \ 17 | rm protoc-$PROTOC_VERSION-linux-x86_64.zip && \ 18 | chmod -R +x /usr/local/bin/protoc 19 | # 安装 librdkafka 20 | RUN curl -LO https://github.com/confluentinc/librdkafka/archive/refs/tags/v$LIBRDKAFKA_VERSION.tar.gz && \ 21 | tar -xzf v$LIBRDKAFKA_VERSION.tar.gz && \ 22 | cd librdkafka-$LIBRDKAFKA_VERSION && \ 23 | ./configure && \ 24 | make && \ 25 | make install && \ 26 | cd .. && \ 27 | rm -rf librdkafka-$LIBRDKAFKA_VERSION v$LIBRDKAFKA_VERSION.tar.gz 28 | 29 | # 安装Rust 30 | RUN curl https://sh.rustup.rs -sSf | sh -s -- -y 31 | 32 | # 配置环境变量以使用Rust命令 33 | ENV PATH="/root/.cargo/bin:${PATH}" 34 | 35 | # 设置工作目录 36 | WORKDIR /usr/src/sandcat-backend 37 | 38 | ## 设置 Cargo 镜像源为国内源(科大镜像) 39 | RUN mkdir -p $HOME/.cargo && \ 40 | echo '[source.crates-io]' > $HOME/.cargo/config.toml && \ 41 | echo 'replace-with = "ustc"' >> $HOME/.cargo/config.toml && \ 42 | echo '[source.ustc]' >> $HOME/.cargo/config.toml && \ 43 | echo 'registry = "https://mirrors.ustc.edu.cn/crates.io-index"' >> $HOME/.cargo/config.toml 44 | 45 | # 复制其余的源代码 46 | COPY . . 47 | 48 | # 设置环境变量 49 | ENV PROTOC=/usr/local/bin/protoc/bin/protoc 50 | 51 | # 构建Rust应用 52 | RUN cargo build --release --features=static 53 | # 第二阶段:运行环境 54 | FROM debian:buster-slim 55 | RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list && \ 56 | apt-get update && \ 57 | apt-get install -y libssl1.1 && \ 58 | apt-get clean && rm -rf /var/lib/apt/lists/* 59 | 60 | # 从构建阶段复制编译好的可执行文件 61 | COPY --from=builder /usr/src/sandcat-backend/target/release/cmd /usr/local/bin/sandcat-backend 62 | 63 | 64 | # 容器启动时运行的命令 65 | CMD ["/usr/src/sandcat-backend/target/release/cmd", "-p", "/usr/local/bin/config.yml"] 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Xu_Mj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IM Backend Architecture Description 2 | 3 | ## Project Overview 4 | 5 | This project provides an implementation of a backend for an Instant Messaging (IM) system, developed in the Rust programming language, with a microservice architecture design. Its primary goal is to offer developers an efficient, stable, and scalable IM solution. The focus of this project is to explore and demonstrate the potential and practices of Rust in building high-performance backend services. 6 | 7 | ## Key Features 8 | 9 | - **Microservice Architecture:** The system is split into multiple independent service units, each responsible for a portion of the core business logic and communication with other services. 10 | - **Containerized Deployment:** All services can be packaged with Docker, facilitating deployment and management. 11 | - **Asynchronous Processing:** Utilizes Rust's asynchronous programming capabilities to handle concurrent workloads, enhancing performance and throughput. 12 | - **Data Storage:** Uses PostgreSQL and MongoDB for storing messages permanently and for inbox functionalities, respectively. 13 | - **Message Queue:** Leverages Kafka as a message queue to support high concurrency message pushing and processing. 14 | 15 | ## Architecture Components 16 | 17 | ![architecture](rfcs/images/framework-english.png) 18 | 19 | 1. **Service Layer** 20 | 21 | - **Authentication Service:** Handles user registration, login, and verification. 22 | - **Message Service:** Responsible for message sending, receiving, and forwarding. 23 | - **Friend Service:** Manages the user's friends list and status. 24 | 25 | - **Group Service:** Takes care of group creation, message broadcasting, and member management. 26 | 27 | 2. **Data Storage Layer** 28 | 29 | - **PostgreSQL:** Storing user information, friendship relations, and message history, along with automated archival through scheduled tasks. 30 | - **MongoDB:** Acts as a message inbox, handling offline message storage and retrieval. 31 | 32 | 3. **Middleware Layer** 33 | 34 | - **Kafka:** Provides a high-throughput message queue to decouple services. 35 | - **Redis:** Implements caching and maintains message status to optimize database load. 36 | 37 | 4. **Infrastructure Layer** 38 | 39 | - **Docker and Docker-Compose:** Containers for building and deploying services. 40 | - **Synapse:** For service registration and discovery. 41 | - **MinIO:** An object storage solution for handling file uploads and downloads. 42 | 43 | ## Performance and Scalability 44 | 45 | The project is designed with high performance and horizontal scalability in mind. Through asynchronous processing and a microservice architecture, the system is capable of scaling effectively by increasing the number of service instances in response to the growing load. Additionally, the project adopts a modular design philosophy that allows developers to customize or replace modules as needed. 46 | 47 | ## Unresolved questions 48 | 49 | - **Integrating Member ID Retrieval from Cache into DB Service**: Whether the method for retrieving member IDs from the cache should be integrated into the DB service is under consideration. 50 | - **Friendship Redesign**: The current design for representing friendships is inadequate and requires a thorough redesign. --rebuilding 51 | - **Conversation Feature**: There is currently no implementation of conversations on the server-side, as it exists only client-side. 52 | - **Partition Table for Messages (Mongodb) Not Implemented**: The strategy for implementing partitioned tables for messages has not been realized yet. 53 | - **User Table Should Add Login Device Field**: There should be consideration to add a field for the login device to the user table, which is used to check if clients need to sync the friend list. 54 | - **Friendship Read Status**: we should delete the Friendship related message after user read it. 55 | - **need to handle friendship/group operations while user desktop and mobile are both online** 56 | - knock off desk from the mobile 57 | - delete minio file by period 58 | - support matrix protocol so that we can import some robot 59 | - maybe we should combine the query send_seq and incr recv_seq into one operation with lua 60 | - add error detail, so that we can log it, but response to frontend need to be short 61 | 62 | ## Development 63 | 64 | 1. install `librdkafka` 65 | 66 | **Ubuntu:** 67 | 68 | ```shell 69 | apt install librdkafka-dev 70 | ``` 71 | 72 | **Windows:** 73 | 74 | ```shell 75 | # install vcpkg 76 | git clone https://github.com/microsoft/vcpkg 77 | cd vcpkg 78 | .\bootstrap-vcpkg.bat 79 | # Install librdkafka 80 | vcpkg install librdkafka 81 | .\vcpkg integrate install 82 | ``` 83 | 84 | 2. run docker compose 85 | 86 | ```shell 87 | docker-compose up -d 88 | ``` 89 | 90 | **important:** make sure all the third service are running in docker. 91 | 92 | 3. install sqlx-cli and init the database 93 | 94 | ```shell 95 | cargo install sqlx-cli 96 | sqlx migrate run 97 | ``` 98 | 99 | 4. clone the project 100 | 101 | ```shell 102 | git clone https://github.com/Xu-Mj/sandcat-backend.git 103 | cd sandcat-backend 104 | ``` 105 | 106 | 5. build 107 | 108 | - **Linux:** use the static feature 109 | 110 | ```shell 111 | cargo build --release --features=static --no-default-features 112 | ``` 113 | 114 | - **Windows:** use the dynamic feature 115 | 116 | ```shell 117 | cargo build --release --features=dynamic 118 | ``` 119 | 120 | 6. copy the binary file to root path 121 | 122 | ```shell 123 | cp target/release/cmd ./sandcat 124 | ``` 125 | 126 | 7. run 127 | 128 | ```shell 129 | ./sandcat 130 | ``` 131 | 132 | if you need adjust some configuration, please modify the `config.yml` 133 | 134 | **important:** Given that our working environment may differ, should you encounter any errors during your deployment, please do let me know. Together, we'll work towards finding a solution. 135 | 136 | ## Contributing 137 | 138 | We follow [Trunk's](https://github.com/trunk-rs/trunk.git) Contribution Guidelines. They are doning a great job. 139 | 140 | Anyone and everyone is welcome to contribute! Please review the [CONTRIBUTING.md](./CONTRIBUTING.md) document for more details. The best way to get started is to find an open issue, and then start hacking on implementing it. Letting other folks know that you are working on it, and sharing progress is a great approach. Open pull requests early and often, and please use GitHub's draft pull request feature. 141 | 142 | ## License 143 | 144 | sandcat is licensed under the terms of the MIT License. 145 | -------------------------------------------------------------------------------- /_typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | flate = "flate" 3 | nin = "nin" 4 | -------------------------------------------------------------------------------- /abi/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "abi" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | 9 | [dependencies] 10 | aws-sdk-s3 = { version = "1.4.0", features = ["rt-tokio"] } 11 | axum = "0.7.4" 12 | bincode = "1.3.3" 13 | bytes = "1" 14 | chrono = { version = "0.4", features = ["serde"] } 15 | derive_builder = "0.20.0" 16 | http = "0.2" 17 | mongodb = "2.8.2" 18 | prost = "0.12" 19 | prost-types = "0.12.3" 20 | redis = "0.25.2" 21 | regex = "1.10.3" 22 | reqwest = "0.12.2" 23 | sqlx = "0.7" 24 | serde = { version = "1", features = ["derive"] } 25 | serde_json = "1" 26 | serde_yaml = "0.9.32" 27 | tonic = { version = "0.11.0", features = ["gzip"] } 28 | tower = "0.4" 29 | tokio = "1" 30 | tracing = "0.1.40" 31 | tracing-subscriber = "0.3.18" 32 | http-body = "1.0.0" 33 | 34 | [build-dependencies] 35 | tonic-build = "0.11.0" 36 | -------------------------------------------------------------------------------- /abi/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | pub trait BuilderExt { 4 | fn with_sqlx_type(self, path: &[&str]) -> Self; 5 | fn with_derive_builder(self, path: &[&str]) -> Self; 6 | fn with_derive_builder_into(self, path: &str, attr: &[&str]) -> Self; 7 | fn with_derive_builder_option(self, path: &str, attr: &[&str]) -> Self; 8 | fn with_serde(self, path: &[&str]) -> Self; 9 | } 10 | 11 | impl BuilderExt for tonic_build::Builder { 12 | // set sqlx::Type for ReservationStatus 13 | fn with_sqlx_type(self, path: &[&str]) -> Self { 14 | // fold func: do somethin with given closure to given initial value; return final value 15 | path.iter().fold(self, |acc, path| { 16 | acc.type_attribute(path, "#[derive(sqlx::Type)]") 17 | }) 18 | } 19 | 20 | fn with_derive_builder(self, path: &[&str]) -> Self { 21 | path.iter().fold(self, |acc, path| { 22 | acc.type_attribute(path, "#[derive(derive_builder::Builder)]") 23 | }) 24 | } 25 | 26 | fn with_derive_builder_into(self, path: &str, field: &[&str]) -> Self { 27 | field.iter().fold(self, |acc, field| { 28 | acc.field_attribute( 29 | format!("{path}.{field}"), 30 | "#[builder(setter(into), default)]", 31 | ) 32 | }) 33 | } 34 | 35 | fn with_derive_builder_option(self, path: &str, field: &[&str]) -> Self { 36 | field.iter().fold(self, |acc, field| { 37 | acc.field_attribute( 38 | format!("{path}.{field}"), 39 | "#[builder(setter(strip_option, into), default)]", 40 | ) 41 | }) 42 | } 43 | 44 | fn with_serde(self, path: &[&str]) -> Self { 45 | path.iter().fold(self, |acc, path| { 46 | acc.type_attribute(path, "#[derive(serde::Serialize, serde::Deserialize)]") 47 | }) 48 | } 49 | } 50 | fn main() { 51 | tonic_build::configure() 52 | .out_dir("src/pb") 53 | .field_attribute("User.password", "#[serde(skip_serializing)]") 54 | .field_attribute("User.salt", "#[serde(skip_serializing)]") 55 | .with_serde(&[ 56 | "PlatformType", 57 | "Msg", 58 | "MsgContent", 59 | "Mention", 60 | "MsgRead", 61 | "MsgToDb", 62 | "GetDbMsgRequest", 63 | "GetDbMessagesRequest", 64 | "DelMsgRequest", 65 | "UserAndGroupID", 66 | "User", 67 | "UserUpdate", 68 | "UserWithMatchType", 69 | "Friend", 70 | "FriendInfo", 71 | "Friendship", 72 | "FriendshipWithUser", 73 | "FsCreate", 74 | "FsCreateRequest", 75 | "FsUpdate", 76 | "UpdateRemarkRequest", 77 | "DeleteFriendRequest", 78 | "AgreeReply", 79 | "Single", 80 | "MsgResponse", 81 | "GroupInfo", 82 | "GroupInviteNew", 83 | "RemoveMemberRequest", 84 | "GetMemberReq", 85 | "GroupMember", 86 | "GroupMemberRole", 87 | "GroupCreate", 88 | "GroupUpdate", 89 | "GroupInvitation", 90 | "GetGroupAndMembersResp", 91 | "SingleCallInvite", 92 | "SingleCallInviteAnswer", 93 | "SingleCallInviteNotAnswer", 94 | "SingleCallInviteCancel", 95 | "SingleCallOffer", 96 | "Hangup", 97 | "AgreeSingleCall", 98 | "Candidate", 99 | ]) 100 | .with_sqlx_type(&["FriendshipStatus", "GroupMemberRole"]) 101 | .compile(&["protos/messages.proto"], &["protos"]) 102 | .unwrap(); 103 | 104 | // execute cargo fmt command 105 | Command::new("cargo").arg("fmt").output().unwrap(); 106 | 107 | println!("cargo: rerun-if-changed=abi/protos/messages.proto"); 108 | } 109 | -------------------------------------------------------------------------------- /abi/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | mod pb; 3 | 4 | pub mod errors; 5 | pub mod types; 6 | mod utils; 7 | 8 | pub use pb::*; 9 | -------------------------------------------------------------------------------- /abi/src/pb/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod message; 2 | -------------------------------------------------------------------------------- /abi/src/types/friend.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::Error; 2 | use crate::message::{ 3 | DeleteFriendRequest, Friend, FriendDb, Friendship, FriendshipStatus, FriendshipWithUser, User, 4 | }; 5 | use sqlx::postgres::PgRow; 6 | use sqlx::{FromRow, Row}; 7 | use std::fmt::{Display, Formatter}; 8 | 9 | impl Display for FriendshipStatus { 10 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 11 | match self { 12 | FriendshipStatus::Pending => write!(f, "Pending"), 13 | FriendshipStatus::Accepted => write!(f, "Accepted"), 14 | FriendshipStatus::Rejected => write!(f, "Rejected"), 15 | FriendshipStatus::Blacked => write!(f, "Blacked"), 16 | FriendshipStatus::Deleted => write!(f, "Deleted"), 17 | } 18 | } 19 | } 20 | #[derive(sqlx::Type, Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] 21 | #[sqlx(type_name = "friend_request_status")] 22 | pub enum FsStatus { 23 | #[default] 24 | Pending, 25 | Accepted, 26 | Rejected, 27 | /// / blacklist 28 | Blacked, 29 | Deleted, 30 | } 31 | 32 | impl From for FriendshipStatus { 33 | fn from(value: FsStatus) -> Self { 34 | match value { 35 | FsStatus::Pending => Self::Pending, 36 | FsStatus::Accepted => Self::Accepted, 37 | FsStatus::Rejected => Self::Rejected, 38 | FsStatus::Blacked => Self::Blacked, 39 | FsStatus::Deleted => Self::Deleted, 40 | } 41 | } 42 | } 43 | 44 | impl FromRow<'_, PgRow> for Friendship { 45 | fn from_row(row: &'_ PgRow) -> Result { 46 | let status: FsStatus = row.try_get("status")?; 47 | let status = FriendshipStatus::from(status); 48 | Ok(Self { 49 | id: row.try_get("id")?, 50 | user_id: row.try_get("user_id")?, 51 | friend_id: row.try_get("friend_id")?, 52 | status: status as i32, 53 | apply_msg: row.try_get("apply_msg")?, 54 | req_remark: row.try_get("req_remark")?, 55 | resp_msg: row.try_get("resp_msg")?, 56 | resp_remark: row.try_get("resp_remark")?, 57 | source: row.try_get("source")?, 58 | create_time: row.try_get("create_time")?, 59 | update_time: row.try_get("update_time")?, 60 | }) 61 | } 62 | } 63 | 64 | impl FromRow<'_, PgRow> for FriendDb { 65 | fn from_row(row: &'_ PgRow) -> Result { 66 | let status: FsStatus = row.try_get("status")?; 67 | let status = FriendshipStatus::from(status); 68 | Ok(Self { 69 | id: row.try_get("id")?, 70 | fs_id: row.try_get("fs_id")?, 71 | user_id: row.try_get("user_id")?, 72 | friend_id: row.try_get("friend_id")?, 73 | status: status as i32, 74 | remark: row.try_get("remark")?, 75 | source: row.try_get("source")?, 76 | create_time: row.try_get("create_time")?, 77 | update_time: row.try_get("update_time")?, 78 | }) 79 | } 80 | } 81 | 82 | impl FromRow<'_, PgRow> for Friend { 83 | fn from_row(row: &'_ PgRow) -> Result { 84 | let status: FsStatus = row.try_get("status").unwrap_or_default(); 85 | let status = FriendshipStatus::from(status); 86 | Ok(Self { 87 | fs_id: row.try_get("fs_id").unwrap_or_default(), 88 | friend_id: row.try_get("friend_id").unwrap_or_default(), 89 | name: row.try_get("name").unwrap_or_default(), 90 | account: row.try_get("account").unwrap_or_default(), 91 | avatar: row.try_get("avatar").unwrap_or_default(), 92 | gender: row.try_get("gender").unwrap_or_default(), 93 | age: row.try_get("age").unwrap_or_default(), 94 | region: row.try_get("region").unwrap_or_default(), 95 | status: status as i32, 96 | remark: row.try_get("remark").unwrap_or_default(), 97 | source: row.try_get("source").unwrap_or_default(), 98 | update_time: row.try_get("update_time").unwrap_or_default(), 99 | signature: row.try_get("signature").unwrap_or_default(), 100 | create_time: row.try_get("create_time").unwrap_or_default(), 101 | email: row.try_get("email").unwrap_or_default(), 102 | }) 103 | } 104 | } 105 | 106 | impl From for FriendshipWithUser { 107 | fn from(value: User) -> Self { 108 | Self { 109 | user_id: value.id, 110 | name: value.name, 111 | account: value.account, 112 | avatar: value.avatar, 113 | gender: value.gender, 114 | age: value.age, 115 | region: value.region, 116 | ..Default::default() 117 | } 118 | } 119 | } 120 | 121 | impl FromRow<'_, PgRow> for FriendshipWithUser { 122 | fn from_row(row: &'_ PgRow) -> Result { 123 | Ok(Self { 124 | fs_id: row.try_get("fs_id").unwrap_or_default(), 125 | user_id: row.try_get("user_id").unwrap_or_default(), 126 | name: row.try_get("name").unwrap_or_default(), 127 | account: row.try_get("account").unwrap_or_default(), 128 | avatar: row.try_get("avatar").unwrap_or_default(), 129 | gender: row.try_get("gender").unwrap_or_default(), 130 | age: row.try_get("age").unwrap_or_default(), 131 | region: row.try_get("region").unwrap_or_default(), 132 | status: row.try_get("status").unwrap_or_default(), 133 | apply_msg: row.try_get("apply_msg").unwrap_or_default(), 134 | source: row.try_get("source").unwrap_or_default(), 135 | create_time: row.try_get("create_time").unwrap_or_default(), 136 | email: row.try_get("email").unwrap_or_default(), 137 | remark: None, 138 | }) 139 | } 140 | } 141 | 142 | impl From for Friend { 143 | fn from(value: User) -> Self { 144 | Self { 145 | fs_id: String::new(), 146 | friend_id: value.id, 147 | name: value.name, 148 | account: value.account, 149 | avatar: value.avatar, 150 | gender: value.gender, 151 | age: value.age, 152 | region: value.region, 153 | status: 0, 154 | remark: None, 155 | source: "".to_string(), 156 | update_time: 0, 157 | signature: value.signature, 158 | create_time: 0, 159 | email: value.email, 160 | } 161 | } 162 | } 163 | 164 | impl DeleteFriendRequest { 165 | pub fn validate(&self) -> Result<(), Error> { 166 | if self.user_id.is_empty() { 167 | return Err(Error::bad_request("user id is none")); 168 | } 169 | 170 | if self.friend_id.is_empty() { 171 | return Err(Error::bad_request("friend id is none")); 172 | } 173 | 174 | if self.fs_id.is_empty() { 175 | return Err(Error::bad_request("friendship id is none")); 176 | } 177 | Ok(()) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /abi/src/types/group.rs: -------------------------------------------------------------------------------- 1 | use sqlx::postgres::PgRow; 2 | use sqlx::{Error, FromRow, Row}; 3 | use tonic::Status; 4 | 5 | use crate::message::{ 6 | GetGroupAndMembersResp, GetMemberReq, GroupInfo, GroupMemSeq, GroupMember, GroupMemberRole, 7 | GroupMembersIdRequest, RemoveMemberRequest, 8 | }; 9 | 10 | use super::Validator; 11 | 12 | impl GroupMembersIdRequest { 13 | pub fn new(group_id: String) -> Self { 14 | Self { group_id } 15 | } 16 | } 17 | 18 | impl Validator for GetMemberReq { 19 | fn validate(&self) -> Result<(), Status> { 20 | if self.group_id.is_empty() { 21 | return Err(Status::invalid_argument("group_id is empty")); 22 | } 23 | if self.user_id.is_empty() { 24 | return Err(Status::invalid_argument("user_id is empty")); 25 | } 26 | if self.mem_ids.is_empty() { 27 | return Err(Status::invalid_argument("mem_ids is empty")); 28 | } 29 | Ok(()) 30 | } 31 | } 32 | 33 | impl Validator for RemoveMemberRequest { 34 | fn validate(&self) -> Result<(), Status> { 35 | if self.group_id.is_empty() { 36 | return Err(Status::invalid_argument("group_id is empty")); 37 | } 38 | if self.user_id.is_empty() { 39 | return Err(Status::invalid_argument("user_id is empty")); 40 | } 41 | if self.mem_id.is_empty() { 42 | return Err(Status::invalid_argument("mem_ids is empty")); 43 | } 44 | Ok(()) 45 | } 46 | } 47 | 48 | impl GetGroupAndMembersResp { 49 | pub fn new(group: GroupInfo, members: Vec) -> Self { 50 | Self { 51 | group: Some(group), 52 | members, 53 | } 54 | } 55 | } 56 | 57 | #[derive(sqlx::Type, Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] 58 | #[sqlx(type_name = "group_role")] 59 | pub enum GroupRole { 60 | Owner, 61 | Admin, 62 | Member, 63 | } 64 | 65 | impl From for GroupMemberRole { 66 | fn from(value: GroupRole) -> Self { 67 | match value { 68 | GroupRole::Owner => Self::Owner, 69 | GroupRole::Admin => Self::Admin, 70 | GroupRole::Member => Self::Member, 71 | } 72 | } 73 | } 74 | 75 | // implement slqx FromRow trait 76 | impl FromRow<'_, PgRow> for GroupMember { 77 | fn from_row(row: &PgRow) -> Result { 78 | let role: GroupRole = row.try_get("role")?; 79 | let role = GroupMemberRole::from(role) as i32; 80 | Ok(Self { 81 | user_id: row.try_get("user_id")?, 82 | group_id: row.try_get("group_id")?, 83 | avatar: row.try_get("avatar")?, 84 | gender: row.try_get("gender")?, 85 | age: row.try_get("age")?, 86 | region: row.try_get("region")?, 87 | group_name: row.try_get("group_name")?, 88 | joined_at: row.try_get("joined_at")?, 89 | remark: None, 90 | signature: row.try_get("signature")?, 91 | role, 92 | }) 93 | } 94 | } 95 | 96 | // implement slqx FromRow trait 97 | impl FromRow<'_, PgRow> for GroupInfo { 98 | fn from_row(row: &PgRow) -> Result { 99 | Ok(Self { 100 | id: row.try_get("id")?, 101 | name: row.try_get("name")?, 102 | owner: row.try_get("owner")?, 103 | avatar: row.try_get("avatar")?, 104 | description: row.try_get("description")?, 105 | announcement: row.try_get("announcement")?, 106 | create_time: row.try_get("create_time")?, 107 | update_time: row.try_get("update_time")?, 108 | }) 109 | } 110 | } 111 | 112 | impl GroupMemSeq { 113 | pub fn new(mem_id: String, cur_seq: i64, max_seq: i64, need_update: bool) -> Self { 114 | Self { 115 | mem_id, 116 | cur_seq, 117 | max_seq, 118 | need_update, 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /abi/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | use tonic::Status; 2 | 3 | mod friend; 4 | mod group; 5 | mod msg; 6 | mod user; 7 | 8 | #[allow(clippy::result_large_err)] 9 | pub trait Validator { 10 | fn validate(&self) -> Result<(), Status>; 11 | } 12 | -------------------------------------------------------------------------------- /abi/src/types/user.rs: -------------------------------------------------------------------------------- 1 | use crate::message::{User, UserWithMatchType}; 2 | use sqlx::postgres::PgRow; 3 | use sqlx::{Error, FromRow, Row}; 4 | 5 | impl FromRow<'_, PgRow> for User { 6 | fn from_row(row: &'_ PgRow) -> Result { 7 | Ok(User { 8 | id: row.try_get("id")?, 9 | name: row.try_get("name")?, 10 | account: row.try_get("account")?, 11 | password: row.try_get("password")?, 12 | avatar: row.try_get("avatar")?, 13 | gender: row.try_get("gender")?, 14 | age: row.try_get("age")?, 15 | phone: row.try_get("phone")?, 16 | email: row.try_get("email")?, 17 | address: row.try_get("address")?, 18 | region: row.try_get("region")?, 19 | birthday: row.try_get("birthday")?, 20 | create_time: row.try_get("create_time")?, 21 | update_time: row.try_get("update_time")?, 22 | salt: row.try_get("salt")?, 23 | signature: row.try_get("signature")?, 24 | }) 25 | } 26 | } 27 | impl FromRow<'_, PgRow> for UserWithMatchType { 28 | fn from_row(row: &'_ PgRow) -> Result { 29 | Ok(UserWithMatchType { 30 | id: row.try_get("id")?, 31 | name: row.try_get("name")?, 32 | account: row.try_get("account")?, 33 | avatar: row.try_get("avatar")?, 34 | gender: row.try_get("gender")?, 35 | age: row.try_get("age")?, 36 | email: row.try_get("email")?, 37 | region: row.try_get("region")?, 38 | birthday: row.try_get("birthday")?, 39 | match_type: row.try_get("match_type")?, 40 | signature: row.try_get("signature")?, 41 | is_friend: false, 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /abi/src/utils.rs: -------------------------------------------------------------------------------- 1 | /// 格式化时间 2 | #[allow(dead_code)] 3 | pub fn format_milliseconds(millis: i64) -> String { 4 | let duration = chrono::Duration::try_milliseconds(millis).unwrap_or_default(); 5 | 6 | let seconds = duration.num_seconds(); 7 | let hours = seconds / 3600; 8 | let minutes = (seconds % 3600) / 60; 9 | let seconds = seconds % 60; 10 | 11 | if hours > 0 { 12 | format!("{:02}:{:02}:{:02}", hours, minutes, seconds) 13 | } else { 14 | format!("{:02}:{:02}", minutes, seconds) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "api" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | 10 | abi = { version = "0.1.0", path = "../abi" } 11 | cache = { version = "0.1.0", path = "../cache" } 12 | db = { version = "0.1.0", path = "../db" } 13 | oss = { version = "0.1.0", path = "../oss" } 14 | utils = { version = "0.1.0", path = "../utils" } 15 | 16 | argon2 = "0.5.3" 17 | axum = { version = "0.7", features = ["multipart"] } 18 | axum-streams = { version = "0.14", features = [ 19 | "json", 20 | "csv", 21 | "protobuf", 22 | "text", 23 | ] } 24 | base64 = "0.22.0" 25 | bincode = "1.3.3" 26 | chrono = "0.4.37" 27 | futures = "0.3" 28 | hyper = "1.2.0" 29 | jsonwebtoken = "9" 30 | lettre = "0.11" 31 | nanoid = "0.4.0" 32 | rand = "0.8.5" 33 | serde = "1" 34 | serde_json = "1" 35 | tera = { version = "1", default-features = false } 36 | tokio = { version = "1", features = ["full"] } 37 | tonic = { version = "0.11.0", features = ["gzip"] } 38 | tracing = "0.1.40" 39 | tracing-subscriber = "0.3.18" 40 | xdb = { git = "https://github.com/lionsoul2014/ip2region.git", branch = "master" } 41 | synapse = { git = "https://github.com/Xu-Mj/synapse.git", branch = "main" } 42 | oauth2 = "4.4.2" 43 | reqwest = { version = "0.12.5", features = ["json"] } 44 | image = "0.25.1" 45 | -------------------------------------------------------------------------------- /api/fixtures/templates/email_temp.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 邮箱验证码 6 | 122 | 123 | 124 | 125 | 126 | 127 | 165 | 166 | 167 |
128 |
129 | 130 | 131 | 132 | 133 | 134 | 135 |
136 |
137 |
138 |
139 | 尊敬的用户:您好! 140 | 141 | 您正在进行注册账号操作,请在验证码中输入以下验证码完成操作: 142 | 143 |
144 | {% for num in numbers %} 145 | 146 | {% endfor %} 147 |
148 |
149 |
150 | 151 | 注意:此操作可能会修改您的密码、登录邮箱或绑定手机。如非本人操作,请及时登录并修改密码以保证帐户安全 152 |
(工作人员不会向你索取此验证码,请勿泄漏!) 153 |
154 |
155 |
156 |
157 |
158 |

此为系统邮件,请勿回复
159 | 请保管好您的邮箱,避免账号被他人盗用 160 |

161 |

—— SandCat-im

162 |
163 |
164 |
168 | 169 | -------------------------------------------------------------------------------- /api/fixtures/xdb/ip2region.xdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/api/fixtures/xdb/ip2region.xdb -------------------------------------------------------------------------------- /api/src/api_utils/custom_extract/auth.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::rejection::{JsonRejection, PathRejection}; 2 | use axum::extract::{FromRef, FromRequestParts, Request}; 3 | use axum::http::request::Parts; 4 | use axum::{ 5 | RequestPartsExt, async_trait, 6 | extract::{FromRequest, MatchedPath}, 7 | http::StatusCode, 8 | }; 9 | use jsonwebtoken::{DecodingKey, Validation, decode}; 10 | use serde::de::DeserializeOwned; 11 | 12 | use abi::errors::Error; 13 | 14 | use crate::AppState; 15 | use crate::handlers::users::Claims; 16 | 17 | pub struct JsonWithAuthExtractor(pub T); 18 | 19 | const AUTHORIZATION_HEADER: &str = "Authorization"; 20 | const BEARER: &str = "Bearer"; 21 | 22 | // The AUTHENTICATION here uses the browser fingerprint plus the user ID, 23 | // otherwise it is not reasonable to force a user to log off if the computer 24 | // is permanently on before the expiration date 25 | // When the user closes the page/app, record the user's closing time. 26 | // The next time the app opens, determine the time interval. 27 | // If it is more than seven days, you need to log in again 28 | 29 | #[async_trait] 30 | impl FromRequest for JsonWithAuthExtractor 31 | where 32 | axum::Json: FromRequest, 33 | AppState: FromRef, 34 | S: Send + Sync, 35 | { 36 | type Rejection = (StatusCode, Error); 37 | 38 | async fn from_request(req: Request, state: &S) -> Result { 39 | let (mut parts, body) = req.into_parts(); 40 | let path = parts 41 | .extract::() 42 | .await 43 | .map(|path| path.as_str().to_owned()) 44 | .ok() 45 | .unwrap_or(String::new()); 46 | let app_state = AppState::from_ref(state); 47 | 48 | if let Some(header) = parts.headers.get(AUTHORIZATION_HEADER) { 49 | // analyze the header 50 | let header = header.to_str().unwrap_or(""); 51 | if !header.starts_with(BEARER) { 52 | return Err(( 53 | StatusCode::UNAUTHORIZED, 54 | Error::unauthorized_with_details(path), 55 | )); 56 | } 57 | let header: Vec<&str> = header.split_whitespace().collect(); 58 | 59 | if let Err(err) = decode::( 60 | header[1], 61 | &DecodingKey::from_secret(app_state.jwt_secret.as_bytes()), 62 | &Validation::default(), 63 | ) { 64 | return Err((StatusCode::INTERNAL_SERVER_ERROR, Error::internal(err))); 65 | } 66 | 67 | let req = Request::from_parts(parts, body); 68 | 69 | match axum::Json::::from_request(req, state).await { 70 | Ok(value) => Ok(Self(value.0)), 71 | // convert the errors from `axum::Json` into whatever we want 72 | Err(rejection) => { 73 | let app_err = Error::body_parsing(rejection.body_text()); 74 | Err((rejection.status(), app_err)) 75 | } 76 | } 77 | } else { 78 | Err(( 79 | StatusCode::UNAUTHORIZED, 80 | Error::unauthorized_with_details(path), 81 | )) 82 | } 83 | } 84 | } 85 | 86 | pub struct PathWithAuthExtractor(pub T); 87 | 88 | #[async_trait] 89 | impl FromRequestParts for PathWithAuthExtractor 90 | where 91 | // these trait bounds are copied from `rpc FromRequest for axum::extract::path::Path` 92 | T: DeserializeOwned + Send, 93 | AppState: FromRef, 94 | S: Send + Sync, 95 | { 96 | type Rejection = (StatusCode, Error); 97 | 98 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 99 | let path = parts 100 | .extract::() 101 | .await 102 | .map(|path| path.as_str().to_owned()) 103 | .ok() 104 | .unwrap_or(String::new()); 105 | let app_state = AppState::from_ref(state); 106 | 107 | if let Some(header) = parts.headers.get(AUTHORIZATION_HEADER) { 108 | // 解析请求头 109 | let header = header.to_str().unwrap_or(""); 110 | if !header.starts_with(BEARER) { 111 | return Err(( 112 | StatusCode::UNAUTHORIZED, 113 | Error::unauthorized_with_details(path), 114 | )); 115 | } 116 | 117 | let header: Vec<&str> = header.split_whitespace().collect(); 118 | 119 | if let Err(err) = decode::( 120 | header[1], 121 | &DecodingKey::from_secret(app_state.jwt_secret.as_bytes()), 122 | &Validation::default(), 123 | ) { 124 | return Err((StatusCode::UNAUTHORIZED, Error::unauthorized(err, path))); 125 | } 126 | 127 | match axum::extract::Path::::from_request_parts(parts, state).await { 128 | Ok(value) => Ok(Self(value.0)), 129 | Err(rejection) => { 130 | let (status, body) = match rejection { 131 | PathRejection::FailedToDeserializePathParams(inner) => { 132 | (StatusCode::BAD_REQUEST, Error::path_parsing(inner)) 133 | } 134 | PathRejection::MissingPathParams(error) => ( 135 | StatusCode::INTERNAL_SERVER_ERROR, 136 | Error::path_parsing(error), 137 | ), 138 | _ => ( 139 | StatusCode::INTERNAL_SERVER_ERROR, 140 | Error::internal_with_details(format!( 141 | "Unhandled path rejection: {rejection}" 142 | )), 143 | ), 144 | }; 145 | 146 | Err((status, body)) 147 | } 148 | } 149 | } else { 150 | Err(( 151 | StatusCode::UNAUTHORIZED, 152 | Error::unauthorized_with_details(path), 153 | )) 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /api/src/api_utils/custom_extract/json_extractor.rs: -------------------------------------------------------------------------------- 1 | use abi::errors::Error; 2 | use axum::{ 3 | async_trait, 4 | extract::{FromRequest, Request, rejection::JsonRejection}, 5 | http::StatusCode, 6 | }; 7 | 8 | pub struct JsonExtractor(pub T); 9 | 10 | #[async_trait] 11 | impl FromRequest for JsonExtractor 12 | where 13 | axum::Json: FromRequest, 14 | S: Send + Sync, 15 | { 16 | type Rejection = (StatusCode, Error); 17 | 18 | async fn from_request(req: Request, state: &S) -> Result { 19 | let (parts, body) = req.into_parts(); 20 | 21 | // We can use other extractors to provide better rejection messages. 22 | // For example, here we are using `axum::extract::MatchedPath` to 23 | // provide a better errors message. 24 | // 25 | // Have to run that first since `Json` extraction consumes the request. 26 | // let path = parts 27 | // .extract::() 28 | // .await 29 | // .map(|path| path.as_str().to_owned()) 30 | // .ok(); 31 | 32 | let req = Request::from_parts(parts, body); 33 | 34 | match axum::Json::::from_request(req, state).await { 35 | Ok(value) => Ok(Self(value.0)), 36 | // convert the errors from `axum::Json` into whatever we want 37 | Err(rejection) => Err(( 38 | rejection.status(), 39 | Error::body_parsing(rejection.body_text()), 40 | )), 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/src/api_utils/custom_extract/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod json_extractor; 3 | pub mod path_extractor; 4 | 5 | pub use auth::*; 6 | pub use json_extractor::*; 7 | pub use path_extractor::*; 8 | -------------------------------------------------------------------------------- /api/src/api_utils/custom_extract/path_extractor.rs: -------------------------------------------------------------------------------- 1 | use abi::errors::Error; 2 | use axum::{ 3 | async_trait, 4 | extract::{FromRequestParts, rejection::PathRejection}, 5 | http::{StatusCode, request::Parts}, 6 | }; 7 | use serde::de::DeserializeOwned; 8 | 9 | // We define our own `Path` extractor that customizes the errors from `axum::extract::Path` 10 | pub struct PathExtractor(pub T); 11 | 12 | #[async_trait] 13 | impl FromRequestParts for PathExtractor 14 | where 15 | // these trait bounds are copied from `rpc FromRequest for axum::extract::path::Path` 16 | T: DeserializeOwned + Send, 17 | S: Send + Sync, 18 | { 19 | type Rejection = (StatusCode, Error); 20 | 21 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 22 | match axum::extract::Path::::from_request_parts(parts, state).await { 23 | Ok(value) => Ok(Self(value.0)), 24 | Err(rejection) => { 25 | let (status, body) = match rejection { 26 | PathRejection::FailedToDeserializePathParams(inner) => { 27 | (StatusCode::BAD_REQUEST, Error::path_parsing(inner)) 28 | } 29 | PathRejection::MissingPathParams(error) => ( 30 | StatusCode::INTERNAL_SERVER_ERROR, 31 | Error::path_parsing(error), 32 | ), 33 | _ => ( 34 | StatusCode::INTERNAL_SERVER_ERROR, 35 | Error::internal_with_details(format!( 36 | "Unhandled path rejection: {rejection}" 37 | )), 38 | ), 39 | }; 40 | 41 | Err((status, body)) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /api/src/api_utils/ip_region/mod.rs: -------------------------------------------------------------------------------- 1 | const CHINA: &str = "中国"; 2 | const INTERNAL_IP: &str = "内网IP"; 3 | const ZERO: &str = "0"; 4 | pub fn parse_region(region: &str) -> Option { 5 | let split: Vec<&str> = region.split('|').collect(); 6 | match split.as_slice() { 7 | [CHINA, _, province, _, _] if province.is_empty() || *province == ZERO => { 8 | Some(String::from(CHINA)) 9 | } 10 | [CHINA, _, province, _, _] => Some(province.to_string()), 11 | [country, _, _, _, _] if *country != CHINA && *country != ZERO => Some(country.to_string()), 12 | [_, _, _, INTERNAL_IP, _] => Some(String::from(INTERNAL_IP)), 13 | _ => None, 14 | } 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | 20 | use super::*; 21 | 22 | #[test] 23 | fn test_multi_type_ip() { 24 | let example1 = "美国|0|新墨西哥|0|康卡斯特"; 25 | let example2 = "中国|0|福建|福州市|电信"; 26 | let example3 = "0|0|0|内网IP|内网IP"; 27 | println!("{:?}", parse_region(example1)); 28 | println!("{:?}", parse_region(example2)); 29 | println!("{:?}", parse_region(example3)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/src/api_utils/lb/mod.rs: -------------------------------------------------------------------------------- 1 | mod strategy; 2 | 3 | use std::collections::BTreeSet; 4 | use std::fmt::Debug; 5 | use std::sync::{Arc, RwLock}; 6 | use synapse::service::ServiceStatus; 7 | use synapse::service::client::ServiceClient; 8 | use tracing::{debug, error}; 9 | 10 | use crate::api_utils::lb::strategy::{LoadBalanceStrategy, LoadBalanceStrategyType, get_strategy}; 11 | 12 | /// load balancer 13 | /// get the service address from consul 14 | #[derive(Debug, Clone)] 15 | pub struct LoadBalancer { 16 | /// service name in consul 17 | service_name: String, 18 | /// register center 19 | service_register: ServiceClient, 20 | /// service set 21 | service_set: Arc>>, 22 | /// load balance strategy 23 | strategy: Arc, 24 | } 25 | 26 | const UPDATE_SERVICE_INTERVAL: u64 = 10; 27 | 28 | impl LoadBalancer { 29 | pub async fn new( 30 | service_name: String, 31 | lb_type: impl Into, 32 | service_register: ServiceClient, 33 | ) -> Self { 34 | let strategy = get_strategy(lb_type.into()); 35 | let mut balancer = Self { 36 | service_name, 37 | service_register, 38 | strategy, 39 | service_set: Arc::new(RwLock::new(BTreeSet::new())), 40 | }; 41 | 42 | balancer.update().await; 43 | 44 | balancer 45 | } 46 | 47 | pub async fn get_service(&self) -> Option { 48 | let services = self.service_set.read().unwrap(); 49 | let services_count = services.len(); 50 | 51 | if services_count == 0 { 52 | None 53 | } else { 54 | // add counter and get the index 55 | let counter = self.strategy.index(services_count); 56 | let index = counter % services_count; 57 | 58 | // reset the counter when the index is 0 59 | // if index == 0 { 60 | // Arc::get_mut(&mut self.strategy).unwrap().reset(); 61 | // } 62 | services.iter().nth(index).cloned() 63 | } 64 | } 65 | 66 | /// update the service address 67 | async fn update(&mut self) { 68 | let mut client = self.service_register.clone(); 69 | let name = self.service_name.clone(); 70 | let set = self.service_set.clone(); 71 | tokio::spawn(async move { 72 | tokio::time::sleep(tokio::time::Duration::from_secs(UPDATE_SERVICE_INTERVAL)).await; 73 | let mut stream = match client.subscribe(name).await { 74 | Ok(stream) => stream, 75 | Err(err) => { 76 | error!("subscribe error: {:?}", err); 77 | return; 78 | } 79 | }; 80 | debug!("subscribe success"); 81 | while let Some(service) = stream.recv().await { 82 | debug!("subscribe channel return: {:?}", service); 83 | let addr = format!("{}:{}", service.address, service.port); 84 | if service.active == ServiceStatus::Up as i32 { 85 | set.write().unwrap().insert(addr); 86 | } else { 87 | set.write().unwrap().remove(&addr); 88 | }; 89 | } 90 | }); 91 | // let services = self 92 | // .service_register 93 | // .filter_by_name(&self.service_name) 94 | // .await 95 | // .unwrap(); 96 | // let service_set = services 97 | // .values() 98 | // .map(|v| format!("{}:{}", v.address, v.port)) 99 | // .collect(); 100 | // 101 | // let old_service_set = self.service_set.read().unwrap(); 102 | // // compare the new service set with the old one 103 | // if *old_service_set == service_set { 104 | // return; 105 | // } 106 | // 107 | // drop(old_service_set); 108 | // 109 | // // update the service set 110 | // let mut old_service_set = self.service_set.write().unwrap(); 111 | // // union the new service set with the old one 112 | // *old_service_set = old_service_set.union(&service_set).cloned().collect(); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /api/src/api_utils/lb/strategy.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | use std::fmt::{Debug, Display, Formatter}; 3 | use std::sync::Arc; 4 | use std::sync::atomic::{AtomicUsize, Ordering}; 5 | 6 | pub fn get_strategy(lb_type: LoadBalanceStrategyType) -> Arc { 7 | match lb_type { 8 | LoadBalanceStrategyType::RoundRobin => Arc::new(RoundRobin::new()), 9 | LoadBalanceStrategyType::Random => Arc::new(RandomStrategy), 10 | } 11 | } 12 | 13 | #[allow(dead_code)] 14 | pub trait LoadBalanceStrategy: Debug + Send + Sync { 15 | fn index(&self, service_count: usize) -> usize; 16 | fn reset(&mut self) {} 17 | } 18 | 19 | pub enum LoadBalanceStrategyType { 20 | RoundRobin, 21 | Random, 22 | } 23 | 24 | impl Display for LoadBalanceStrategyType { 25 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 26 | match self { 27 | LoadBalanceStrategyType::RoundRobin => write!(f, "RoundRobin"), 28 | LoadBalanceStrategyType::Random => write!(f, "Random"), 29 | } 30 | } 31 | } 32 | 33 | impl From for LoadBalanceStrategyType { 34 | fn from(value: String) -> Self { 35 | match value.as_str() { 36 | "RoundRobin" => Self::RoundRobin, 37 | "Random" => Self::Random, 38 | _ => Self::RoundRobin, 39 | } 40 | } 41 | } 42 | 43 | #[derive(Debug)] 44 | pub struct RoundRobin { 45 | /// counter, record the number of the request 46 | counter: AtomicUsize, 47 | } 48 | 49 | impl LoadBalanceStrategy for RoundRobin { 50 | fn index(&self, service_count: usize) -> usize { 51 | let index = self.counter.fetch_add(1, Ordering::Relaxed); 52 | index % service_count 53 | } 54 | 55 | fn reset(&mut self) { 56 | self.counter.store(0, Ordering::Relaxed); 57 | } 58 | } 59 | 60 | impl RoundRobin { 61 | pub fn new() -> Self { 62 | Self { 63 | counter: AtomicUsize::new(0), 64 | } 65 | } 66 | } 67 | 68 | #[derive(Debug)] 69 | pub struct RandomStrategy; 70 | 71 | impl LoadBalanceStrategy for RandomStrategy { 72 | fn index(&self, service_count: usize) -> usize { 73 | rand::thread_rng().gen_range(0..service_count) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /api/src/api_utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod custom_extract; 2 | pub mod ip_region; 3 | pub mod lb; 4 | -------------------------------------------------------------------------------- /api/src/handlers/files/file.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::{Multipart, State}; 2 | use axum::http::HeaderMap; 3 | use nanoid::nanoid; 4 | use std::ffi::OsStr; 5 | use std::path::Path; 6 | 7 | use abi::errors::Error; 8 | 9 | use crate::AppState; 10 | use crate::api_utils::custom_extract::PathExtractor; 11 | 12 | pub async fn upload( 13 | State(state): State, 14 | mut multipart: Multipart, 15 | ) -> Result { 16 | let mut filename = String::new(); 17 | if let Some(field) = multipart.next_field().await.map_err(Error::internal)? { 18 | filename = field.file_name().unwrap_or_default().to_string(); 19 | let extension = Path::new(&filename).extension().and_then(OsStr::to_str); 20 | // filename = extension.map(|v| format!("{}.{}", nanoid!(), v)).unwrap_or(nanoid!()); 21 | filename = match extension { 22 | None => nanoid!(), 23 | Some(e) => format!("{}.{}", nanoid!(), e), 24 | }; 25 | 26 | let data = field.bytes().await.map_err(Error::internal)?; 27 | state.oss.upload_file(&filename, data.into()).await?; 28 | } 29 | 30 | Ok(filename) 31 | } 32 | 33 | pub async fn get_file_by_name( 34 | State(state): State, 35 | PathExtractor(filename): PathExtractor, 36 | ) -> Result<(HeaderMap, Vec), Error> { 37 | let bytes = state.oss.download_file(&filename).await?; 38 | let mut headers = HeaderMap::with_capacity(1); 39 | headers.insert( 40 | "Cache-Control", 41 | "private, max-age=31536000".parse().unwrap(), 42 | ); 43 | Ok((headers, bytes.into())) 44 | } 45 | 46 | pub async fn get_avatar_by_name( 47 | State(state): State, 48 | PathExtractor(filename): PathExtractor, 49 | ) -> Result<(HeaderMap, Vec), Error> { 50 | let bytes = state.oss.download_avatar(&filename).await?; 51 | let mut headers = HeaderMap::with_capacity(1); 52 | headers.insert( 53 | "Cache-Control", 54 | "private, max-age=31536000".parse().unwrap(), 55 | ); 56 | Ok((headers, bytes.into())) 57 | } 58 | 59 | pub async fn upload_avatar( 60 | State(state): State, 61 | mut multipart: Multipart, 62 | ) -> Result { 63 | let mut filename = String::new(); 64 | if let Some(field) = multipart.next_field().await.map_err(Error::internal)? { 65 | filename = field.file_name().unwrap_or_default().to_string(); 66 | let extension = Path::new(&filename).extension().and_then(OsStr::to_str); 67 | filename = match extension { 68 | None => nanoid!(), 69 | Some(e) => format!("{}.{}", nanoid!(), e), 70 | }; 71 | 72 | let data = field.bytes().await.map_err(Error::internal)?; 73 | state.oss.upload_avatar(&filename, data.into()).await?; 74 | } 75 | 76 | Ok(filename) 77 | } 78 | -------------------------------------------------------------------------------- /api/src/handlers/files/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod file; 2 | -------------------------------------------------------------------------------- /api/src/handlers/friends/friend_handlers.rs: -------------------------------------------------------------------------------- 1 | use axum::Json; 2 | use axum::extract::State; 3 | use serde::Serialize; 4 | 5 | use abi::errors::Error; 6 | use abi::message::{ 7 | AgreeReply, DeleteFriendRequest, Friend, FriendInfo, FriendshipWithUser, FsCreate, 8 | SendMsgRequest, UpdateRemarkRequest, 9 | }; 10 | 11 | use crate::AppState; 12 | use crate::api_utils::custom_extract::{JsonWithAuthExtractor, PathWithAuthExtractor}; 13 | 14 | #[derive(Serialize)] 15 | pub struct FriendShipList { 16 | pub friends: Vec, 17 | pub fs: Vec, 18 | } 19 | 20 | pub async fn get_friends_list_by_user_id( 21 | State(app_state): State, 22 | PathWithAuthExtractor((user_id, offline_time)): PathWithAuthExtractor<(String, i64)>, 23 | ) -> Result, Error> { 24 | let friends = app_state 25 | .db 26 | .friend 27 | .get_friend_list(&user_id, offline_time) 28 | .await?; 29 | let fs = app_state 30 | .db 31 | .friend 32 | .get_fs_list(&user_id, offline_time) 33 | .await?; 34 | Ok(Json(FriendShipList { friends, fs })) 35 | } 36 | 37 | // 获取好友申请列表 38 | pub async fn get_apply_list_by_user_id( 39 | State(app_state): State, 40 | PathWithAuthExtractor((user_id, offline_time)): PathWithAuthExtractor<(String, i64)>, 41 | ) -> Result>, Error> { 42 | let list = app_state 43 | .db 44 | .friend 45 | .get_fs_list(&user_id, offline_time) 46 | .await?; 47 | Ok(Json(list)) 48 | } 49 | 50 | // 创建好友请求 51 | pub async fn create_friendship( 52 | State(app_state): State, 53 | JsonWithAuthExtractor(new_friend): JsonWithAuthExtractor, 54 | ) -> Result, Error> { 55 | tracing::debug!("{:?}", &new_friend); 56 | let receiver_id = new_friend.friend_id.clone(); 57 | let (fs_req, fs_send) = app_state.db.friend.create_fs(new_friend).await?; 58 | 59 | // decode fs 60 | let fs = bincode::serialize(&fs_send)?; 61 | 62 | // increase send sequence 63 | let (cur_seq, _, _) = app_state.cache.incr_send_seq(&fs_send.user_id).await?; 64 | 65 | // send create fs message for online user 66 | let msg = SendMsgRequest::new_with_friend_ship_req(fs_send.user_id, receiver_id, fs, cur_seq); 67 | let mut chat_rpc = app_state.chat_rpc.clone(); 68 | // need to send message to mq, because need to store 69 | chat_rpc.send_msg(msg).await?; 70 | Ok(Json(fs_req)) 71 | } 72 | 73 | // 同意好友请求, 需要friend db参数 74 | pub async fn agree( 75 | State(app_state): State, 76 | JsonWithAuthExtractor(agree): JsonWithAuthExtractor, 77 | ) -> Result, Error> { 78 | // 同意好友添加请求,需要向同意方返回请求方的个人信息,向请求方发送同意方的个人信息 79 | let (req, send) = app_state 80 | .db 81 | .friend 82 | .agree_friend_apply_request(agree) 83 | .await?; 84 | 85 | let send_id = send.friend_id.clone(); 86 | // decode friend 87 | let friend = bincode::serialize(&send)?; 88 | 89 | // increase send sequence 90 | let (cur_seq, _, _) = app_state.cache.incr_send_seq(&send_id).await?; 91 | 92 | // send message 93 | let mut chat_rpc = app_state.chat_rpc.clone(); 94 | chat_rpc 95 | .send_msg(SendMsgRequest::new_with_friend_ship_resp( 96 | req.friend_id.clone(), 97 | friend, 98 | cur_seq, 99 | )) 100 | .await?; 101 | Ok(Json(req)) 102 | } 103 | 104 | // 拉黑/取消拉黑 105 | // pub async fn black_list( 106 | // State(app_state): State, 107 | // JsonWithAuthExtractor(relation): JsonWithAuthExtractor, 108 | // ) -> Result<(), FriendError> { 109 | // update_friend_status( 110 | // &app_state.pool, 111 | // relation.user_id, 112 | // relation.friend_id, 113 | // relation.status, 114 | // ) 115 | // .await 116 | // .map_err(|err| FriendError::InternalServerError(err.to_string()))?; 117 | // Ok(()) 118 | // } 119 | 120 | pub async fn delete_friend( 121 | State(app_state): State, 122 | JsonWithAuthExtractor(req): JsonWithAuthExtractor, 123 | ) -> Result<(), Error> { 124 | req.validate()?; 125 | 126 | app_state 127 | .db 128 | .friend 129 | .delete_friend(&req.fs_id, &req.user_id) 130 | .await?; 131 | 132 | // send message to friend 133 | let msg = SendMsgRequest::new_with_friend_del(req.user_id, req.friend_id); 134 | let mut chat_rpc = app_state.chat_rpc.clone(); 135 | chat_rpc.send_msg(msg).await?; 136 | Ok(()) 137 | } 138 | 139 | pub async fn update_friend_remark( 140 | State(app_state): State, 141 | JsonWithAuthExtractor(relation): JsonWithAuthExtractor, 142 | ) -> Result<(), Error> { 143 | if relation.remark.is_empty() { 144 | return Err(Error::bad_request("remark is none")); 145 | } 146 | app_state 147 | .db 148 | .friend 149 | .update_friend_remark(&relation.user_id, &relation.friend_id, &relation.remark) 150 | .await?; 151 | Ok(()) 152 | } 153 | 154 | pub async fn query_friend_info( 155 | State(app_state): State, 156 | PathWithAuthExtractor(user_id): PathWithAuthExtractor, 157 | ) -> Result, Error> { 158 | let user = app_state 159 | .db 160 | .user 161 | .get_user_by_id(&user_id) 162 | .await? 163 | .ok_or(Error::not_found())?; 164 | let friend = FriendInfo { 165 | id: user.id, 166 | name: user.name, 167 | region: user.region, 168 | gender: user.gender, 169 | avatar: user.avatar, 170 | account: user.account, 171 | signature: user.signature, 172 | email: user.email, 173 | age: user.age, 174 | }; 175 | Ok(Json(friend)) 176 | } 177 | -------------------------------------------------------------------------------- /api/src/handlers/friends/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod friend_handlers; 2 | -------------------------------------------------------------------------------- /api/src/handlers/groups/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod group_handlers; 2 | -------------------------------------------------------------------------------- /api/src/handlers/messages/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod msg_handlers; 2 | -------------------------------------------------------------------------------- /api/src/handlers/messages/msg_handlers.rs: -------------------------------------------------------------------------------- 1 | use axum::Json; 2 | use axum::extract::State; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use abi::errors::Error; 6 | use abi::message::{DelMsgRequest, GetDbMessagesRequest, Msg}; 7 | 8 | use crate::AppState; 9 | use crate::api_utils::custom_extract::{JsonWithAuthExtractor, PathWithAuthExtractor}; 10 | 11 | // message handler, offer the ability to pull offline message 12 | // #[allow(dead_code)] 13 | // pub async fn pull_offline_msg_stream( 14 | // State(state): State, 15 | // JsonWithAuthExtractor(req): JsonWithAuthExtractor, 16 | // ) -> Result, Error> { 17 | // let mut db_rpc = state.db_rpc.clone(); 18 | // // validate 19 | // req.validate()?; 20 | // 21 | // // request db rpc 22 | // let response = db_rpc.get_msg_stream(req).await.map_err(|e| { 23 | // Error::InternalServer(format!( 24 | // "procedure db rpc service error: get_messages {:?}", 25 | // e 26 | // )) 27 | // })?; 28 | // let stream = response.into_inner(); 29 | // 30 | // let stream = stream.map(|msg_result| { 31 | // msg_result 32 | // .map_err(|e| Error::InternalServer(e.to_string())) 33 | // .and_then(|msg| { 34 | // bincode::serialize(&msg) 35 | // .map(hyper::body::Bytes::from) 36 | // .map_err(|e| Error::InternalServer(e.to_string())) 37 | // }) 38 | // }); 39 | // let body = Body::wrap_stream(stream); // 转换流 40 | // 41 | // // 创建axum的HTTP响应并绑定body 42 | // Ok(Response::new(body)) 43 | // } 44 | 45 | pub async fn pull_offline_messages( 46 | State(state): State, 47 | JsonWithAuthExtractor(req): JsonWithAuthExtractor, 48 | ) -> Result>, Error> { 49 | let result = state 50 | .msg_box 51 | .get_msgs( 52 | &req.user_id, 53 | req.send_start, 54 | req.send_end, 55 | req.start, 56 | req.end, 57 | ) 58 | .await?; 59 | Ok(Json(result)) 60 | } 61 | 62 | #[derive(Serialize, Deserialize, Debug)] 63 | pub struct Seq { 64 | pub seq: i64, 65 | pub send_seq: i64, 66 | } 67 | 68 | pub async fn get_seq( 69 | State(state): State, 70 | PathWithAuthExtractor(user_id): PathWithAuthExtractor, 71 | ) -> Result, Error> { 72 | let seq = state.cache.get_cur_seq(&user_id).await?; 73 | Ok(Json(Seq { 74 | seq: seq.0, 75 | send_seq: seq.1, 76 | })) 77 | } 78 | 79 | pub async fn del_msg( 80 | State(state): State, 81 | JsonWithAuthExtractor(req): JsonWithAuthExtractor, 82 | ) -> Result<(), Error> { 83 | state 84 | .msg_box 85 | .delete_messages(&req.user_id, req.msg_id) 86 | .await?; 87 | Ok(()) 88 | } 89 | -------------------------------------------------------------------------------- /api/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod files; 2 | pub(crate) mod friends; 3 | pub(crate) mod groups; 4 | pub(crate) mod messages; 5 | pub(crate) mod users; 6 | -------------------------------------------------------------------------------- /api/src/handlers/users/mod.rs: -------------------------------------------------------------------------------- 1 | use std::net::{IpAddr, SocketAddr}; 2 | 3 | use axum::Json; 4 | use base64::prelude::*; 5 | use jsonwebtoken::{EncodingKey, Header, encode}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | use abi::errors::Error; 9 | use abi::message::User; 10 | 11 | mod oauth2; 12 | mod user_handlers; 13 | 14 | pub use oauth2::*; 15 | use tracing::error; 16 | pub use user_handlers::*; 17 | use xdb::search_by_ip; 18 | 19 | use crate::AppState; 20 | use crate::api_utils::ip_region::parse_region; 21 | 22 | // 定义request model 23 | #[derive(Debug, Deserialize, Serialize)] 24 | pub struct UserRegister { 25 | pub avatar: String, 26 | pub name: String, 27 | pub password: String, 28 | pub email: String, 29 | pub code: String, 30 | } 31 | 32 | #[derive(Serialize)] 33 | pub struct Token { 34 | user: User, 35 | token: String, 36 | refresh_token: String, 37 | ws_addr: String, 38 | } 39 | 40 | #[derive(Deserialize, Debug)] 41 | pub struct LoginRequest { 42 | pub account: String, 43 | pub password: String, 44 | } 45 | 46 | impl LoginRequest { 47 | pub fn decode(&mut self) -> Result<(), Error> { 48 | // base64 decode 49 | if self.account.is_empty() || self.password.is_empty() { 50 | return Err(Error::bad_request("parameter is none")); 51 | } 52 | let pwd = BASE64_STANDARD_NO_PAD 53 | .decode(&self.password) 54 | .map_err(Error::internal)?; 55 | self.password = String::from_utf8(pwd).map_err(Error::internal)?; 56 | Ok(()) 57 | } 58 | } 59 | 60 | pub const REFRESH_EXPIRES: i64 = 24 * 60 * 60; 61 | 62 | #[derive(Debug, Serialize, Deserialize)] 63 | pub struct Claims { 64 | pub sub: String, 65 | pub exp: i64, 66 | pub iat: i64, 67 | } 68 | 69 | const EXPIRES: i64 = 60 * 60 * 4; 70 | 71 | impl Claims { 72 | pub fn new(sub: String) -> Self { 73 | let now = chrono::Utc::now().timestamp(); 74 | let exp = now + EXPIRES; 75 | Self { sub, exp, iat: now } 76 | } 77 | } 78 | 79 | pub async fn gen_token( 80 | app_state: &AppState, 81 | mut user: User, 82 | addr: SocketAddr, 83 | ) -> Result, Error> { 84 | // generate token 85 | let mut claims = Claims::new(user.name.clone()); 86 | 87 | let token = encode( 88 | &Header::default(), 89 | &claims, 90 | &EncodingKey::from_secret(app_state.jwt_secret.as_bytes()), 91 | ) 92 | .map_err(Error::internal)?; 93 | 94 | claims.exp += REFRESH_EXPIRES; 95 | let refresh_token = encode( 96 | &Header::default(), 97 | &claims, 98 | &EncodingKey::from_secret(app_state.jwt_secret.as_bytes()), 99 | ) 100 | .map_err(Error::internal)?; 101 | 102 | app_state.cache.user_login(&user.account).await?; 103 | 104 | // get websocket service address 105 | // let ws_lb = Arc::get_mut(&mut app_state.ws_lb).unwrap(); 106 | let ws_addr = app_state 107 | .ws_lb 108 | .get_service() 109 | .await 110 | .map(|addr| format!("{}://{}/ws", &app_state.ws_config.protocol, addr)) 111 | .ok_or(Error::internal_with_details( 112 | "No websocket service available", 113 | ))?; 114 | 115 | // query region 116 | user.region = match addr.ip() { 117 | IpAddr::V4(ip) => match search_by_ip(ip) { 118 | Ok(region) => parse_region(®ion), 119 | Err(e) => { 120 | error!("search region error: {:?}", e); 121 | None 122 | } 123 | }, 124 | IpAddr::V6(_) => None, 125 | }; 126 | 127 | if user.region.is_some() { 128 | app_state 129 | .db 130 | .user 131 | .update_region(&user.id, user.region.as_ref().unwrap()) 132 | .await?; 133 | } 134 | 135 | Ok(Json(Token { 136 | user, 137 | token, 138 | refresh_token, 139 | ws_addr, 140 | })) 141 | } 142 | 143 | #[derive(Debug, Deserialize, Serialize)] 144 | pub struct ModifyPwdRequest { 145 | pub user_id: String, 146 | pub email: String, 147 | pub pwd: String, 148 | pub code: String, 149 | } 150 | 151 | impl ModifyPwdRequest { 152 | pub fn decode(&mut self) -> Result<(), Error> { 153 | // base64 decode 154 | if self.user_id.is_empty() 155 | || self.email.is_empty() 156 | || self.pwd.is_empty() 157 | || self.code.is_empty() 158 | { 159 | return Err(Error::bad_request("parameter is none")); 160 | } 161 | let pwd = BASE64_STANDARD_NO_PAD 162 | .decode(&self.pwd) 163 | .map_err(Error::internal)?; 164 | self.pwd = String::from_utf8(pwd).map_err(Error::internal)?; 165 | Ok(()) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /api/src/handlers/users/oauth2.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use abi::{errors::Error, message::User}; 4 | use axum::{ 5 | Json, 6 | extract::{ConnectInfo, Query, State}, 7 | response::{IntoResponse, Redirect}, 8 | }; 9 | use hyper::header; 10 | use nanoid::nanoid; 11 | use oauth2::{ 12 | AuthorizationCode, CsrfToken, EmptyExtraTokenFields, Scope, StandardTokenResponse, 13 | TokenResponse, 14 | basic::{BasicClient, BasicTokenType}, 15 | reqwest::async_http_client, 16 | }; 17 | use serde::Deserialize; 18 | use tracing::info; 19 | 20 | use crate::AppState; 21 | 22 | use super::{Token, gen_token}; 23 | 24 | const AUTHORIZATION_HEADER_PREFIX: &str = "Bearer "; 25 | const USER_AGENT: &str = "SandCat-Auth"; 26 | 27 | pub async fn github_login(State(state): State) -> impl IntoResponse { 28 | let authorize_url = get_auth_url(&state.oauth2_clients.github).await; 29 | 30 | Redirect::temporary(&authorize_url.to_string()) 31 | } 32 | 33 | /// todo need to handle state, use cache to store state, and verify it in callback 34 | #[derive(Debug, Deserialize)] 35 | #[allow(dead_code)] 36 | pub struct AuthResp { 37 | pub code: String, 38 | pub state: String, 39 | } 40 | 41 | #[derive(Debug, Deserialize, Default)] 42 | #[allow(dead_code)] 43 | pub struct GitHubUser { 44 | pub name: String, 45 | pub avatar_url: String, 46 | } 47 | 48 | #[derive(Debug, Deserialize)] 49 | #[allow(dead_code)] 50 | pub struct GitHubEmail { 51 | pub email: String, 52 | pub primary: bool, 53 | } 54 | 55 | pub async fn github_callback( 56 | Query(auth): Query, 57 | ConnectInfo(addr): ConnectInfo, 58 | State(state): State, 59 | ) -> Result, Error> { 60 | let token_result = get_token_result(&state.oauth2_clients.github, auth.code).await?; 61 | 62 | let access_token = token_result.access_token().secret(); 63 | 64 | // request user's email 65 | let user_emails: Vec = reqwest::Client::new() 66 | .get(&state.oauth2_config.github.email_url) 67 | .header( 68 | header::AUTHORIZATION, 69 | format!("{} {}", AUTHORIZATION_HEADER_PREFIX, access_token), 70 | ) 71 | .header(header::USER_AGENT, USER_AGENT) 72 | .send() 73 | .await? 74 | .json() 75 | .await?; 76 | 77 | let email = user_emails 78 | .into_iter() 79 | .find(|item| item.primary) 80 | .ok_or(Error::not_found())?; 81 | 82 | // select user info from db by email 83 | let mut user_info = state.db.user.get_user_by_email(&email.email).await?; 84 | 85 | // if none, need to get user info from github and register 86 | if user_info.is_none() { 87 | user_info = Some(register_user(&state, email.email, access_token).await?); 88 | } 89 | 90 | gen_token(&state, user_info.unwrap(), addr).await 91 | } 92 | 93 | pub async fn google_login(State(state): State) -> impl IntoResponse { 94 | let authorize_url = get_auth_url(&state.oauth2_clients.google).await; 95 | 96 | Redirect::temporary(&authorize_url.to_string()) 97 | } 98 | 99 | pub async fn google_callback( 100 | Query(auth): Query, 101 | State(state): State, 102 | ) -> impl IntoResponse { 103 | let token_result = get_token_result(&state.oauth2_clients.google, auth.code).await; 104 | info!("{:?}", token_result); 105 | } 106 | 107 | async fn get_auth_url(client: &BasicClient) -> String { 108 | let (authorize_url, _csrf_state) = client 109 | .authorize_url(CsrfToken::new_random) 110 | .add_scope(Scope::new(String::from("read:user"))) 111 | .add_scope(Scope::new(String::from("user:email"))) 112 | .url(); 113 | authorize_url.to_string() 114 | } 115 | 116 | async fn get_token_result( 117 | client: &BasicClient, 118 | code: String, 119 | ) -> Result, Error> { 120 | client 121 | .exchange_code(AuthorizationCode::new(code)) 122 | .request_async(async_http_client) 123 | .await 124 | .map_err(Error::internal) 125 | } 126 | 127 | async fn download_avatar(url: &str, state: &AppState) -> Result { 128 | let content = reqwest::get(url).await?.bytes().await?; 129 | 130 | // get image type 131 | let mut filename = nanoid!(); 132 | if let Ok(tp) = image::guess_format(&content) { 133 | filename = format!("{}.{}", filename, tp.extensions_str().first().unwrap()); 134 | } 135 | 136 | let oss = state.oss.clone(); 137 | oss.upload_avatar(&filename, content.into()).await?; 138 | Ok(filename) 139 | } 140 | 141 | async fn register_user(state: &AppState, email: String, access_token: &str) -> Result { 142 | let user: GitHubUser = reqwest::Client::new() 143 | .get(&state.oauth2_config.github.user_info_url) 144 | .header( 145 | header::AUTHORIZATION, 146 | format!("{} {}", AUTHORIZATION_HEADER_PREFIX, access_token), 147 | ) 148 | .header(header::USER_AGENT, USER_AGENT) 149 | .send() 150 | .await? 151 | .json() 152 | .await?; 153 | 154 | // download avatar from github 155 | 156 | let avatar = download_avatar(&user.avatar_url, state).await?; 157 | 158 | let id = nanoid!(); 159 | // convert user to db user 160 | let user2db = User { 161 | id: id.clone(), 162 | name: user.name, 163 | account: id, 164 | email: Some(email), 165 | avatar, 166 | ..Default::default() 167 | }; 168 | 169 | let user = state.db.user.create_user(user2db).await?; 170 | Ok(user) 171 | } 172 | -------------------------------------------------------------------------------- /api/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | use std::sync::Arc; 3 | 4 | use db::message::MsgRecBoxRepo; 5 | use oauth2::basic::BasicClient; 6 | use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; 7 | use synapse::service::client::ServiceClient; 8 | use xdb::searcher_init; 9 | 10 | use abi::config::{Config, MailConfig, OAuth2, OAuth2Item, WsServerConfig}; 11 | use abi::message::chat_service_client::ChatServiceClient; 12 | use cache::Cache; 13 | use db::{DbRepo, msg_rec_box_repo}; 14 | use oss::Oss; 15 | use utils::service_discovery::LbWithServiceDiscovery; 16 | 17 | use crate::api_utils::lb; 18 | 19 | mod api_utils; 20 | pub(crate) mod handlers; 21 | pub(crate) mod routes; 22 | 23 | #[derive(Clone, Debug)] 24 | pub struct AppState { 25 | // pub db_rpc: DbServiceClient, 26 | pub db: Arc, 27 | pub msg_box: Arc, 28 | pub chat_rpc: ChatServiceClient, 29 | pub cache: Arc, 30 | pub oss: Arc, 31 | pub ws_lb: Arc, 32 | pub ws_config: WsServerConfig, 33 | pub mail_config: MailConfig, 34 | pub jwt_secret: String, 35 | pub oauth2_config: OAuth2, 36 | pub oauth2_clients: OAuth2Clients, 37 | } 38 | 39 | #[derive(Clone, Debug)] 40 | pub struct OAuth2Clients { 41 | pub github: BasicClient, 42 | pub google: BasicClient, 43 | } 44 | 45 | impl AppState { 46 | pub async fn new(config: &Config) -> Self { 47 | let db = Arc::new(DbRepo::new(config).await); 48 | let msg_box = msg_rec_box_repo(config).await; 49 | 50 | let chat_rpc = utils::get_rpc_client(config, config.rpc.chat.name.clone()) 51 | .await 52 | .unwrap(); 53 | 54 | let cache = cache::cache(config); 55 | 56 | let oss = oss::oss(config).await; 57 | 58 | let ws_config = config.websocket.clone(); 59 | 60 | let mail_config = config.mail.clone(); 61 | let client = ServiceClient::builder() 62 | .server_host(config.service_center.host.clone()) 63 | .server_port(config.service_center.port) 64 | .build() 65 | .await 66 | .expect("build service client failed"); 67 | let ws_lb = Arc::new( 68 | lb::LoadBalancer::new( 69 | config.websocket.name.clone(), 70 | config.server.ws_lb_strategy.clone(), 71 | client, 72 | ) 73 | .await, 74 | ); 75 | 76 | let oauth2_config = config.server.oauth2.clone(); 77 | let oauth2_clients = init_oauth2(config); 78 | 79 | Self { 80 | db, 81 | msg_box, 82 | cache, 83 | oss, 84 | ws_lb, 85 | ws_config, 86 | mail_config, 87 | jwt_secret: config.server.jwt_secret.clone(), 88 | chat_rpc, 89 | oauth2_config, 90 | oauth2_clients, 91 | } 92 | } 93 | } 94 | 95 | fn init_oauth2(config: &Config) -> OAuth2Clients { 96 | let google = init_oauth2_client(&config.server.oauth2.google); 97 | let github = init_oauth2_client(&config.server.oauth2.github); 98 | OAuth2Clients { github, google } 99 | } 100 | 101 | fn init_oauth2_client(oauth2_config: &OAuth2Item) -> BasicClient { 102 | let client_id = ClientId::new(oauth2_config.client_id.clone()); 103 | let client_secret = ClientSecret::new(oauth2_config.client_secret.clone()); 104 | let auth_url = AuthUrl::new(oauth2_config.auth_url.clone()).unwrap(); 105 | let token_url = TokenUrl::new(oauth2_config.token_url.clone()).unwrap(); 106 | let client = BasicClient::new(client_id, Some(client_secret), auth_url, Some(token_url)); 107 | client.set_redirect_uri(RedirectUrl::new(oauth2_config.redirect_url.clone()).unwrap()) 108 | } 109 | 110 | pub async fn start(config: Config) { 111 | // init search ip region xdb 112 | searcher_init(Some(config.db.xdb.clone())); 113 | let state = AppState::new(&config).await; 114 | let app = routes::app_routes(state.clone()); 115 | let listener = tokio::net::TcpListener::bind(&config.server.server_url()) 116 | .await 117 | .unwrap(); 118 | tracing::debug!("listening on {}", listener.local_addr().unwrap()); 119 | axum::serve( 120 | listener, 121 | app.into_make_service_with_connect_info::(), 122 | ) 123 | .await 124 | .unwrap(); 125 | } 126 | -------------------------------------------------------------------------------- /api/src/main.rs: -------------------------------------------------------------------------------- 1 | use abi::config::Config; 2 | use tracing::Level; 3 | 4 | use api::start; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | tracing_subscriber::fmt() 9 | .with_line_number(true) 10 | .with_max_level(Level::DEBUG) 11 | .init(); 12 | 13 | let config = Config::load("config.yml").unwrap(); 14 | start(config).await; 15 | } 16 | -------------------------------------------------------------------------------- /api/src/routes.rs: -------------------------------------------------------------------------------- 1 | use axum::Router; 2 | use axum::extract::DefaultBodyLimit; 3 | use axum::routing::{delete, get, post, put}; 4 | 5 | use crate::AppState; 6 | use crate::handlers::files::file::{get_avatar_by_name, get_file_by_name, upload, upload_avatar}; 7 | use crate::handlers::friends::friend_handlers::{ 8 | agree, create_friendship, delete_friend, get_apply_list_by_user_id, 9 | get_friends_list_by_user_id, query_friend_info, update_friend_remark, 10 | }; 11 | use crate::handlers::groups::group_handlers::{ 12 | create_group_handler, delete_group_handler, get_group, get_group_and_members, 13 | get_group_members, invite_new_members, remove_member, update_group_handler, 14 | }; 15 | use crate::handlers::messages::msg_handlers::{del_msg, get_seq, pull_offline_messages}; 16 | use crate::handlers::users::{ 17 | create_user, get_user_by_id, github_callback, github_login, google_callback, google_login, 18 | login, logout, modify_pwd, refresh_token, search_user, send_email, update_user, 19 | }; 20 | 21 | pub(crate) fn app_routes(state: AppState) -> Router { 22 | Router::new() 23 | .nest("/user", user_routes(state.clone())) 24 | .nest("/friend", friend_routes(state.clone())) 25 | .nest("/file", file_routes(state.clone())) 26 | .nest("/group", group_routes(state.clone())) 27 | .nest("/message", msg_routes(state.clone())) 28 | } 29 | 30 | fn friend_routes(state: AppState) -> Router { 31 | Router::new() 32 | .route("/", post(create_friendship)) 33 | .route("/:id/:offline_time", get(get_friends_list_by_user_id)) 34 | .route("/:id/apply", get(get_apply_list_by_user_id)) 35 | .route("/agree", put(agree)) 36 | .route("/", delete(delete_friend)) 37 | .route("/remark", put(update_friend_remark)) 38 | .route("/query/:user_id", get(query_friend_info)) 39 | .with_state(state) 40 | } 41 | 42 | fn user_routes(state: AppState) -> Router { 43 | Router::new() 44 | .route("/", post(create_user)) 45 | .route("/", put(update_user)) 46 | .route("/pwd", put(modify_pwd)) 47 | .route("/:id", get(get_user_by_id)) 48 | .route("/refresh_token/:token/:is_refresh", get(refresh_token)) 49 | .route("/:user_id/search/:pattern", get(search_user)) 50 | .route("/login", post(login)) 51 | .route("/logout/:uuid", delete(logout)) 52 | .route("/mail/send", post(send_email)) 53 | .route("/auth/wechat", get(google_login)) 54 | .route("/auth/wechat/callback", get(google_callback)) 55 | .route("/auth/github", get(github_login)) 56 | .route("/auth/github/callback", get(github_callback)) 57 | .with_state(state) 58 | } 59 | 60 | fn group_routes(state: AppState) -> Router { 61 | Router::new() 62 | .route("/:user_id/:group_id", get(get_group)) 63 | .route("/:user_id", post(create_group_handler)) 64 | .route("/invite", put(invite_new_members)) 65 | .route("/", delete(delete_group_handler)) 66 | .route("/:user_id", put(update_group_handler)) 67 | .route("/member/:user_id/:group_id", get(get_group_and_members)) 68 | .route("/member", post(get_group_members)) 69 | .route("/member", delete(remove_member)) 70 | .with_state(state) 71 | } 72 | 73 | const MAX_FILE_UPLOAD_SIZE: usize = 1024 * 1024 * 50; 74 | fn file_routes(state: AppState) -> Router { 75 | Router::new() 76 | .route( 77 | "/upload", 78 | post(upload).layer(DefaultBodyLimit::max(MAX_FILE_UPLOAD_SIZE)), 79 | ) 80 | .route("/get/:filename", get(get_file_by_name)) 81 | .route( 82 | "/avatar/upload", 83 | post(upload_avatar).layer(DefaultBodyLimit::max(MAX_FILE_UPLOAD_SIZE)), 84 | ) 85 | .route("/avatar/get/:filename", get(get_avatar_by_name)) 86 | .with_state(state) 87 | } 88 | 89 | fn msg_routes(state: AppState) -> Router { 90 | Router::new() 91 | .route("/", post(pull_offline_messages)) 92 | .route("/seq/:user_id", get(get_seq)) 93 | .route("/", delete(del_msg)) 94 | .with_state(state) 95 | } 96 | -------------------------------------------------------------------------------- /cache/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cache" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | # as a basic library we can't rely on the other crates besise abi, 9 | # because abi is the foundation of the whole project 10 | [dependencies] 11 | abi = { version = "0.1.0", path = "../abi" } 12 | 13 | async-trait = "0.1.79" 14 | redis = { version = "0.25.2", features = ["tokio-comp"] } 15 | 16 | [dev-dependencies] 17 | tokio = { version = "1.36.0", features = ["full"] } 18 | tracing = "0.1.40" 19 | tracing-subscriber = "0.3.18" 20 | -------------------------------------------------------------------------------- /cache/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | use std::sync::Arc; 3 | 4 | use abi::message::GroupMemSeq; 5 | use async_trait::async_trait; 6 | 7 | use abi::config::Config; 8 | use abi::errors::Error; 9 | 10 | mod redis; 11 | 12 | #[async_trait] 13 | pub trait Cache: Sync + Send + Debug { 14 | /// check if the sequence is loaded 15 | async fn check_seq_loaded(&self) -> Result; 16 | 17 | /// set the sequence is loaded 18 | async fn set_seq_loaded(&self) -> Result<(), Error>; 19 | 20 | /// set the receive sequence 21 | /// contains: user_id, send_max_seq, recv_max_seq 22 | async fn set_seq(&self, max_seq: &[(String, i64, i64)]) -> Result<(), Error>; 23 | 24 | /// set the send sequence 25 | async fn set_send_seq(&self, max_seq: &[(String, i64)]) -> Result<(), Error>; 26 | 27 | /// query receive sequence by user id 28 | async fn get_seq(&self, user_id: &str) -> Result; 29 | /// query current send sequence and receive sequence by user id 30 | async fn get_cur_seq(&self, user_id: &str) -> Result<(i64, i64), Error>; 31 | 32 | /// query send sequence by user id, 33 | /// it returns the current send sequence and the max send sequence 34 | async fn get_send_seq(&self, user_id: &str) -> Result<(i64, i64), Error>; 35 | 36 | /// increase receive sequence by user id 37 | async fn increase_seq(&self, user_id: &str) -> Result<(i64, i64, bool), Error>; 38 | 39 | /// increase send sequence by user id 40 | async fn incr_send_seq(&self, user_id: &str) -> Result<(i64, i64, bool), Error>; 41 | 42 | /// INCREASE GROUP MEMBERS SEQUENCE 43 | async fn incr_group_seq(&self, mut members: Vec) -> Result, Error>; 44 | 45 | /// query group members id 46 | async fn query_group_members_id(&self, group_id: &str) -> Result, Error>; 47 | 48 | /// save group members id, usually called when create group 49 | async fn save_group_members_id( 50 | &self, 51 | group_id: &str, 52 | members_id: Vec, 53 | ) -> Result<(), Error>; 54 | 55 | /// add one member id to group members id set 56 | async fn add_group_member_id(&self, member_id: &str, group_id: &str) -> Result<(), Error>; 57 | 58 | /// remove the group member id from the group members id set 59 | async fn remove_group_member_id(&self, group_id: &str, member_id: &str) -> Result<(), Error>; 60 | 61 | async fn remove_group_member_batch( 62 | &self, 63 | group_id: &str, 64 | member_id: &[&str], 65 | ) -> Result<(), Error>; 66 | 67 | /// return the members id 68 | async fn del_group_members(&self, group_id: &str) -> Result<(), Error>; 69 | 70 | /// save register code 71 | async fn save_register_code(&self, email: &str, code: &str) -> Result<(), Error>; 72 | 73 | /// get register code 74 | async fn get_register_code(&self, email: &str) -> Result, Error>; 75 | 76 | /// delete the register code after user register 77 | async fn del_register_code(&self, email: &str) -> Result<(), Error>; 78 | 79 | /// user login 80 | async fn user_login(&self, user_id: &str) -> Result<(), Error>; 81 | 82 | /// user logout 83 | async fn user_logout(&self, user_id: &str) -> Result<(), Error>; 84 | 85 | /// online count 86 | async fn online_count(&self) -> Result; 87 | } 88 | 89 | pub fn cache(config: &Config) -> Arc { 90 | Arc::new(redis::RedisCache::from_config(config)) 91 | } 92 | -------------------------------------------------------------------------------- /cmd/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cmd" 3 | authors = ["Xu-mj"] 4 | version = "0.1.0" 5 | edition = "2021" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | abi = { version = "0.1.0", path = "../abi" } 11 | api = { version = "0.1.0", path = "../api" } 12 | cache = { version = "0.1.0", path = "../cache" } 13 | db = { version = "0.1.0", path = "../db" } 14 | msg_gateway = { version = "0.1.0", path = "../msg_gateway" } 15 | msg_server = { version = "0.1.0", path = "../msg_server" } 16 | utils = { version = "0.1.0", path = "../utils" } 17 | 18 | chrono = "0.4" 19 | clap = { version = "4.5.4", features = ["cargo"] } 20 | tokio = { version = "1.36.0", features = ["full"] } 21 | tracing = "0.1.40" 22 | tracing-subscriber = { version = "0.3.18", features = ["time"] } 23 | tracing-appender = "0.2.3" 24 | 25 | [features] 26 | default = ["dynamic"] 27 | dynamic = ["msg_server/dynamic"] 28 | static = ["msg_server/static"] 29 | -------------------------------------------------------------------------------- /cmd/src/load_seq.rs: -------------------------------------------------------------------------------- 1 | use abi::config::Config; 2 | use tracing::{error, info}; 3 | 4 | // 缓存中设置一个标志,用来标识是否已经加载过用户的max_seq 5 | pub async fn load_seq(config: &Config) { 6 | info!("check is loaded"); 7 | let redis = cache::cache(config); 8 | 9 | let flag = redis.check_seq_loaded().await.unwrap(); 10 | if !flag { 11 | info!("seq already loaded"); 12 | return; 13 | } 14 | 15 | info!("seq not loaded"); 16 | info!("loading seq start..."); 17 | let db_repo = db::DbRepo::new(config).await; 18 | let mut rx = db_repo.seq.get_max_seq().await.unwrap(); 19 | let batch_size = 50; 20 | let mut list = Vec::with_capacity(batch_size); 21 | while let Some(item) = rx.recv().await { 22 | list.push(item); 23 | if list.len() == batch_size { 24 | if let Err(e) = redis.set_seq(&list).await { 25 | error!("set seq error: {:?}", e) 26 | }; 27 | list.clear(); 28 | } 29 | } 30 | 31 | if !list.is_empty() { 32 | if let Err(e) = redis.set_seq(&list).await { 33 | error!("set seq error: {:?}", e) 34 | }; 35 | list.clear(); 36 | } 37 | 38 | // set loaded flag 39 | redis.set_seq_loaded().await.unwrap(); 40 | } 41 | -------------------------------------------------------------------------------- /cmd/src/main.rs: -------------------------------------------------------------------------------- 1 | mod load_seq; 2 | 3 | use clap::{command, Arg}; 4 | use tracing::{error, info}; 5 | use tracing_subscriber::fmt::format::Writer; 6 | use tracing_subscriber::fmt::time::FormatTime; 7 | 8 | use abi::config::{Component, Config}; 9 | use load_seq::load_seq; 10 | use msg_gateway::ws_server::WsServer; 11 | 12 | const DEFAULT_CONFIG_PATH: &str = "./config.yml"; 13 | struct LocalTimer; 14 | 15 | impl FormatTime for LocalTimer { 16 | fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result { 17 | let local_time = chrono::Local::now(); 18 | write!(w, "{}", local_time.format("%Y-%m-%dT%H:%M:%S%.6f%:z")) 19 | } 20 | } 21 | 22 | #[tokio::main] 23 | async fn main() { 24 | // chat rely on kafka server 25 | // consumer rely on kafka server; 26 | 27 | // ws rely on chat; 28 | // consumer rely on db and pusher rpc server; 29 | 30 | // get configuration path 31 | let matches = command!() 32 | .version(env!("CARGO_PKG_VERSION")) 33 | .author(env!("CARGO_PKG_AUTHORS")) 34 | .about(env!("CARGO_PKG_DESCRIPTION")) 35 | .arg( 36 | Arg::new("configuration") 37 | .short('p') 38 | .long("configuration") 39 | .value_name("CONFIGURATION") 40 | .default_value(DEFAULT_CONFIG_PATH) 41 | .help("Set the configuration path"), 42 | ) 43 | .get_matches(); 44 | let default_config = DEFAULT_CONFIG_PATH.to_string(); 45 | let configuration = matches 46 | .get_one::("configuration") 47 | .unwrap_or(&default_config); 48 | 49 | info!("load configuration from: {:?}", configuration); 50 | let config = Config::load(configuration).unwrap(); 51 | 52 | // init tracing 53 | if config.log.output != "console" { 54 | // redirect log to file 55 | let file_appender = tracing_appender::rolling::daily(&config.log.output, "sandcat"); 56 | let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender); 57 | // builder = builder.with_writer(non_blocking); 58 | tracing_subscriber::FmtSubscriber::builder() 59 | .with_line_number(true) 60 | .with_max_level(config.log.level()) 61 | .with_writer(non_blocking) 62 | .with_timer(LocalTimer) 63 | .init(); 64 | } else { 65 | // log to console 66 | tracing_subscriber::FmtSubscriber::builder() 67 | .with_line_number(true) 68 | .with_max_level(config.log.level()) 69 | .with_timer(LocalTimer) 70 | .init(); 71 | } 72 | // check if redis need to load seq 73 | load_seq(&config).await; 74 | 75 | match config.component { 76 | Component::Api => api::start(config.clone()).await, 77 | Component::MessageGateway => WsServer::start(config.clone()).await, 78 | Component::MessageServer => msg_server::start(&config).await, 79 | Component::All => start_all(config).await, 80 | } 81 | } 82 | 83 | async fn start_all(config: Config) { 84 | // start ws rpc server 85 | let cloned_config = config.clone(); 86 | let ws_server = tokio::spawn(async move { 87 | WsServer::start(cloned_config).await; 88 | }); 89 | 90 | // start cleaner 91 | db::clean_receive_box(&config).await; 92 | 93 | // start api server 94 | let cloned_config = config.clone(); 95 | let api_server = tokio::spawn(async move { 96 | api::start(cloned_config).await; 97 | }); 98 | 99 | // start consumer rpc server 100 | let consumer_server = tokio::spawn(async move { 101 | msg_server::start(&config).await; 102 | }); 103 | 104 | // wait all server stop 105 | tokio::select! { 106 | _ = ws_server => {error!("ws server down!")}, 107 | _ = api_server => {error!("api server down!")}, 108 | _ = consumer_server => {error!("consumer server down!")}, 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /config-docker.yml: -------------------------------------------------------------------------------- 1 | component: all # all, api, ws, rpc, db, pusher 2 | log: 3 | level: info 4 | output: console 5 | 6 | db: 7 | postgres: 8 | host: postgres 9 | port: 5432 10 | user: postgres 11 | password: postgres 12 | database: im 13 | mongodb: 14 | host: mongodb 15 | port: 27017 16 | user: 17 | password: 18 | database: im 19 | xdb: /usr/src/sandcat-backend/api/fixtures/xdb/ip2region.xdb 20 | 21 | server: 22 | host: 127.0.0.1 23 | port: 50001 24 | jwt_secret: sandcat 25 | ws_lb_strategy: RoundRobin # Random, RoundRobin 26 | 27 | service_center: 28 | host: synapse 29 | port: 8500 30 | timeout: 5000 31 | protocol: http 32 | 33 | websocket: 34 | protocol: ws 35 | host: 127.0.0.1 36 | port: 50000 37 | name: websocket 38 | tags: 39 | - websocket 40 | - grpc 41 | 42 | 43 | rpc: 44 | health_check: false # no need to start the health check under dev mode 45 | ws: 46 | protocol: http 47 | host: 127.0.0.1 48 | port: 50002 49 | name: ws 50 | tags: 51 | - ws 52 | - grpc 53 | grpc_health_check: 54 | grpc_use_tls: false 55 | interval: 30 # second 56 | chat: 57 | protocol: http 58 | host: 127.0.0.1 59 | port: 50003 60 | name: chat 61 | tags: 62 | - chat 63 | - grpc 64 | grpc_health_check: 65 | grpc_use_tls: false 66 | interval: 30000 # second 67 | db: 68 | protocol: http 69 | host: 127.0.0.1 70 | port: 50004 71 | name: db 72 | tags: 73 | - db 74 | - grpc 75 | grpc_health_check: 76 | grpc_use_tls: false 77 | interval: 30000 # second 78 | pusher: 79 | protocol: http 80 | host: 127.0.0.1 81 | port: 50005 82 | name: pusher 83 | tags: 84 | - pusher 85 | - grpc 86 | grpc_health_check: 87 | grpc_use_tls: false 88 | interval: 30000 # second 89 | 90 | redis: 91 | host: redis 92 | port: 6379 93 | seq_step: 10000 94 | 95 | kafka: 96 | hosts: 97 | - kafka:9092 98 | topic: sandcat-chat 99 | group: chat 100 | connect_timeout: 5000 # milliseconds 101 | producer: 102 | timeout: 3000 103 | acks: all # 0: no response, 1: leader response, all: all response 104 | max_retry: 3 105 | retry_interval: 1000 # retry interval in milliseconds 106 | consumer: 107 | auto_offset_reset: earliest # earliest, latest 108 | session_timeout: 20000 109 | 110 | 111 | oss: 112 | endpoint: minio:9000 113 | access_key: minioadmin 114 | secret_key: minioadmin 115 | bucket: sandcat 116 | avatar_bucket: sandcat-avatar 117 | region: us-east-1 118 | 119 | mail: 120 | # server: smtp.qq.com 121 | server: 127.0.0.1 122 | account: sandcat@sandcat.com 123 | # password: rxkhmcpjgigsbegi 124 | password: sandcat.email.password!~ 125 | temp_path: ./api/fixtures/templates/* 126 | temp_file: email_temp.html 127 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | component: all # all, api, ws, rpc, db, pusher 2 | log: 3 | level: debug 4 | output: console 5 | 6 | db: 7 | postgres: 8 | host: 127.0.0.1 9 | port: 5432 10 | user: postgres 11 | password: postgres 12 | database: im 13 | mongodb: 14 | host: 127.0.0.1 15 | port: 27017 16 | user: 17 | password: 18 | database: im 19 | clean: 20 | period: 3600 # days 21 | except_types: 22 | - "MsgTypeGroupInvitation" 23 | - "MsgTypeGroupInviteNew" 24 | - "MsgTypeGroupMemberExit" 25 | - "MsgTypeGroupRemoveMember" 26 | - "MsgTypeGroupDismiss" 27 | - "MsgTypeGroupUpdate" 28 | - "MsgTypeFriendApplyReq" 29 | - "MsgTypeFriendApplyResp" 30 | - "MsgTypeFriendBlack" 31 | - "MsgTypeFriendDelete" 32 | 33 | xdb: ./api/fixtures/xdb/ip2region.xdb 34 | 35 | server: 36 | host: 127.0.0.1 37 | port: 50001 38 | jwt_secret: sandcat 39 | ws_lb_strategy: RoundRobin # Random, RoundRobin 40 | oauth2: 41 | google: 42 | client_id: 1001171385100-dgsbppvvuc43pho1e5dp4i53ki7p4ugn.apps.googleusercontent.com 43 | client_secret: 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 44 | auth_url: https://accounts.google.com/o/oauth2/v2/auth 45 | token_url: https://oauth2.googleapis.com/token 46 | redirect_url: http://localhost:8080/third_login_callback/google 47 | user_info_url: https://api.github.com/user 48 | email_url: https://api.github.com/user/emails 49 | github: 50 | client_id: Ov23liLVEltedOYkqDfJ 51 | client_secret: f77f680d9380ac04c4eeda646bd4c3fd14882c68 52 | auth_url: https://github.com/login/oauth/authorize 53 | token_url: https://github.com/login/oauth/access_token 54 | # redirect_url: http://localhost:50001/user/auth/github/callback 55 | redirect_url: http://localhost:8080/third_login_callback/github 56 | user_info_url: https://api.github.com/user 57 | email_url: https://api.github.com/user/emails 58 | 59 | service_center: 60 | host: 127.0.0.1 61 | port: 8500 62 | timeout: 5000 63 | protocol: http 64 | 65 | websocket: 66 | protocol: ws 67 | host: 127.0.0.1 68 | port: 50000 69 | name: websocket 70 | tags: 71 | - websocket 72 | - grpc 73 | 74 | 75 | rpc: 76 | health_check: false # no need to start the health check under dev mode 77 | ws: 78 | protocol: http 79 | host: 127.0.0.1 80 | port: 50002 81 | name: ws 82 | tags: 83 | - ws 84 | - grpc 85 | grpc_health_check: 86 | grpc_use_tls: false 87 | interval: 30 # second 88 | chat: 89 | protocol: http 90 | host: 127.0.0.1 91 | port: 50003 92 | name: chat 93 | tags: 94 | - chat 95 | - grpc 96 | grpc_health_check: 97 | grpc_use_tls: false 98 | interval: 30000 # second 99 | db: 100 | protocol: http 101 | host: 127.0.0.1 102 | port: 50004 103 | name: db 104 | tags: 105 | - db 106 | - grpc 107 | grpc_health_check: 108 | grpc_use_tls: false 109 | interval: 30000 # second 110 | pusher: 111 | protocol: http 112 | host: 127.0.0.1 113 | port: 50005 114 | name: pusher 115 | tags: 116 | - pusher 117 | - grpc 118 | grpc_health_check: 119 | grpc_use_tls: false 120 | interval: 30000 # second 121 | 122 | redis: 123 | host: 127.0.0.1 124 | port: 6379 125 | seq_step: 10000 126 | 127 | kafka: 128 | hosts: 129 | - 127.0.0.1:9092 130 | topic: sandcat-chat 131 | group: chat 132 | connect_timeout: 5000 # milliseconds 133 | producer: 134 | timeout: 3000 135 | acks: all # 0: no response, 1: leader response, all: all response 136 | max_retry: 3 137 | retry_interval: 1000 # retry interval in milliseconds 138 | consumer: 139 | auto_offset_reset: earliest # earliest, latest 140 | session_timeout: 20000 141 | 142 | 143 | oss: 144 | endpoint: http://127.0.0.1:9000 145 | access_key: minioadmin 146 | secret_key: minioadmin 147 | bucket: sandcat 148 | avatar_bucket: sandcat-avatar 149 | region: us-east-1 150 | 151 | mail: 152 | server: smtp.qq.com 153 | # server: 127.0.0.1 154 | # account: sandcat@sandcat.com 155 | account: 653609824@qq.com 156 | password: rxkhmcpjgigsbegi 157 | # password: sandcat.email.password!~ 158 | temp_path: ./api/fixtures/templates/* 159 | temp_file: email_temp.html 160 | -------------------------------------------------------------------------------- /db/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "db" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | abi = { version = "0.1.0", path = "../abi" } 10 | cache = { version = "0.1.0", path = "../cache" } 11 | utils = { version = "0.1.0", path = "../utils" } 12 | 13 | argon2 = "0.5.3" 14 | async-trait = "0.1.79" 15 | bson = { version = "2.9.0", features = ["chrono-0_4"] } 16 | chrono = { version = "0.4.31", features = ["serde"] } 17 | futures = "0.3.30" 18 | mongodb = "2.8.2" 19 | nanoid = "0.4.0" 20 | serde = { version = "1.0", features = ["derive"] } 21 | serde_json = "1.0" 22 | sqlx = { version = "0.7", features = [ 23 | "runtime-tokio-rustls", 24 | "postgres", 25 | "chrono", 26 | ] } 27 | tokio = { version = "1.36.0", features = ["full"] } 28 | tonic = { version = "0.11.0", features = ["gzip"] } 29 | tracing = "0.1" 30 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 31 | synapse = { git = "https://github.com/Xu-Mj/synapse.git", branch = "main" } 32 | -------------------------------------------------------------------------------- /db/src/friend.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use async_trait::async_trait; 4 | 5 | use abi::errors::Error; 6 | use abi::message::{ 7 | AgreeReply, Friend, FriendDb, Friendship, FriendshipStatus, FriendshipWithUser, FsCreate, 8 | FsUpdate, 9 | }; 10 | 11 | #[async_trait] 12 | pub trait FriendRepo: Send + Sync + Debug { 13 | /// create friend apply request, ignore friendship status in fs, it always be pending 14 | async fn create_fs( 15 | &self, 16 | fs: FsCreate, 17 | ) -> Result<(FriendshipWithUser, FriendshipWithUser), Error>; 18 | 19 | // is it necessary to exists? 20 | // async fn get_fs(&self, user_id: &str, friend_id: &str) -> Result; 21 | 22 | /// get friend apply request list 23 | async fn get_fs_list( 24 | &self, 25 | user_id: &str, 26 | offline_time: i64, 27 | ) -> Result, Error>; 28 | 29 | /// update friend apply request 30 | #[allow(dead_code)] 31 | async fn update_fs(&self, fs: FsUpdate) -> Result; 32 | 33 | /// update friend remark; the status should be accepted 34 | async fn update_friend_remark( 35 | &self, 36 | user_id: &str, 37 | friend_id: &str, 38 | remark: &str, 39 | ) -> Result; 40 | 41 | /// update friend status; the status should be accepted or blocked. 42 | /// this is not that to agree friend-apply-request 43 | #[allow(dead_code)] 44 | async fn update_friend_status( 45 | &self, 46 | user_id: &str, 47 | friend_id: &str, 48 | status: FriendshipStatus, 49 | ) -> Result; 50 | 51 | async fn get_friend_list(&self, user_id: &str, offline_time: i64) 52 | -> Result, Error>; 53 | // ) -> Result>, Error>; 54 | 55 | /// agree friend-apply-request 56 | async fn agree_friend_apply_request(&self, fs: AgreeReply) -> Result<(Friend, Friend), Error>; 57 | 58 | async fn delete_friend(&self, fs_id: &str, user_id: &str) -> Result<(), Error>; 59 | } 60 | -------------------------------------------------------------------------------- /db/src/group.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use async_trait::async_trait; 4 | 5 | use abi::errors::Error; 6 | use abi::message::{ 7 | GetGroupAndMembersResp, GroupCreate, GroupInfo, GroupInvitation, GroupInviteNew, GroupMember, 8 | GroupUpdate, 9 | }; 10 | 11 | #[async_trait] 12 | pub trait GroupStoreRepo: Sync + Send + Debug { 13 | async fn get_group(&self, user_id: &str, group_id: &str) -> Result; 14 | 15 | async fn get_group_and_members( 16 | &self, 17 | user_id: &str, 18 | group_id: &str, 19 | ) -> Result; 20 | 21 | async fn get_members( 22 | &self, 23 | user_id: &str, 24 | group_id: &str, 25 | mem_ids: Vec, 26 | ) -> Result, Error>; 27 | 28 | async fn create_group_with_members( 29 | &self, 30 | group: &GroupCreate, 31 | ) -> Result; 32 | 33 | async fn invite_new_members(&self, group: &GroupInviteNew) -> Result<(), Error>; 34 | 35 | async fn remove_member( 36 | &self, 37 | group_id: &str, 38 | user_id: &str, 39 | mem_ids: &[String], 40 | ) -> Result<(), Error>; 41 | 42 | #[allow(dead_code)] 43 | async fn get_group_by_id(&self, group_id: &str) -> Result; 44 | 45 | async fn query_group_members_id(&self, group_id: &str) -> Result, Error>; 46 | 47 | #[allow(dead_code)] 48 | async fn query_group_members_by_group_id( 49 | &self, 50 | group_id: &str, 51 | ) -> Result, Error>; 52 | 53 | async fn update_group(&self, group: &GroupUpdate) -> Result; 54 | 55 | async fn exit_group(&self, user_id: &str, group_id: &str) -> Result<(), Error>; 56 | 57 | async fn delete_group(&self, group_id: &str, owner: &str) -> Result; 58 | } 59 | -------------------------------------------------------------------------------- /db/src/lib.rs: -------------------------------------------------------------------------------- 1 | use friend::FriendRepo; 2 | use group::GroupStoreRepo; 3 | use seq::SeqRepo; 4 | use tracing::info; 5 | 6 | use abi::{config::Config, message::MsgType}; 7 | use user::UserRepo; 8 | 9 | mod mongodb; 10 | mod postgres; 11 | 12 | pub mod friend; 13 | pub mod group; 14 | pub mod message; 15 | // pub mod rpc; 16 | pub mod seq; 17 | pub mod user; 18 | 19 | use std::sync::Arc; 20 | 21 | use message::{MsgRecBoxCleaner, MsgRecBoxRepo, MsgStoreRepo}; 22 | use sqlx::PgPool; 23 | 24 | /// shall we create a structure to hold everything we need? 25 | /// like db pool and mongodb's database 26 | #[derive(Debug)] 27 | pub struct DbRepo { 28 | pub msg: Box, 29 | pub group: Box, 30 | pub user: Box, 31 | pub friend: Box, 32 | pub seq: Box, 33 | } 34 | 35 | impl DbRepo { 36 | pub async fn new(config: &Config) -> Self { 37 | let pool = PgPool::connect(&config.db.postgres.url()).await.unwrap(); 38 | let seq_step = config.redis.seq_step; 39 | 40 | let msg = Box::new(postgres::PostgresMessage::new(pool.clone())); 41 | let user = Box::new(postgres::PostgresUser::new(pool.clone(), seq_step)); 42 | let friend = Box::new(postgres::PostgresFriend::new(pool.clone())); 43 | let group = Box::new(postgres::PostgresGroup::new(pool.clone())); 44 | let seq = Box::new(postgres::PostgresSeq::new(pool, seq_step)); 45 | Self { 46 | msg, 47 | group, 48 | user, 49 | friend, 50 | seq, 51 | } 52 | } 53 | } 54 | 55 | pub async fn msg_rec_box_repo(config: &Config) -> Arc { 56 | Arc::new(mongodb::MsgBox::from_config(config).await) 57 | } 58 | 59 | pub async fn msg_rec_box_cleaner(config: &Config) -> Arc { 60 | Arc::new(mongodb::MsgBox::from_config(config).await) 61 | } 62 | 63 | pub async fn clean_receive_box(config: &Config) { 64 | let types: Vec = config 65 | .db 66 | .mongodb 67 | .clean 68 | .except_types 69 | .iter() 70 | .filter_map(|v| MsgType::from_str_name(v)) 71 | .map(|v| v as i32) 72 | .collect(); 73 | let period = config.db.mongodb.clean.period; 74 | 75 | let msg_box = msg_rec_box_cleaner(config).await; 76 | info!( 77 | "clean receive box task started, and the period is {period}s; the except types is {:?}", 78 | types 79 | ); 80 | msg_box.clean_receive_box(period, types); 81 | } 82 | -------------------------------------------------------------------------------- /db/src/main.rs: -------------------------------------------------------------------------------- 1 | use tracing::Level; 2 | 3 | use abi::config::Config; 4 | // use db::rpc::DbRpcService; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | tracing_subscriber::fmt() 9 | .with_max_level(Level::DEBUG) 10 | .with_line_number(true) 11 | .init(); 12 | 13 | let config = Config::load("config.yml").unwrap(); 14 | 15 | // start cleaner 16 | db::clean_receive_box(&config).await; 17 | 18 | // start rpc service 19 | // DbRpcService::start(&config).await; 20 | } 21 | -------------------------------------------------------------------------------- /db/src/message.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use async_trait::async_trait; 4 | use tokio::sync::mpsc; 5 | 6 | use abi::errors::Error; 7 | use abi::message::{GroupMemSeq, Msg}; 8 | 9 | /// face to postgres db 10 | #[async_trait] 11 | pub trait MsgStoreRepo: Sync + Send + Debug { 12 | /// save message to db 13 | async fn save_message(&self, message: Msg) -> Result<(), Error>; 14 | } 15 | 16 | /// message receive box 17 | /// face to mongodb 18 | /// when user received message, will delete message from receive box 19 | #[async_trait] 20 | pub trait MsgRecBoxRepo: Sync + Send + Debug { 21 | /// save message, need message structure 22 | async fn save_message(&self, message: &Msg) -> Result<(), Error>; 23 | 24 | /// save message to message receive box 25 | /// need the group members id 26 | async fn save_group_msg(&self, message: Msg, members: Vec) -> Result<(), Error>; 27 | 28 | async fn delete_message(&self, message_id: &str) -> Result<(), Error>; 29 | 30 | async fn delete_messages(&self, user_id: &str, msg_seq: Vec) -> Result<(), Error>; 31 | 32 | #[allow(dead_code)] 33 | async fn get_message(&self, message_id: &str) -> Result, Error>; 34 | 35 | /// need to think about how to get message from receive box, 36 | /// use stream? or use pagination? prefer stream 37 | async fn get_messages_stream( 38 | &self, 39 | user_id: &str, 40 | start: i64, 41 | end: i64, 42 | ) -> Result>, Error>; 43 | 44 | #[deprecated] 45 | async fn get_messages(&self, user_id: &str, start: i64, end: i64) -> Result, Error>; 46 | 47 | async fn get_msgs( 48 | &self, 49 | user_id: &str, 50 | send_start: i64, 51 | send_end: i64, 52 | rec_start: i64, 53 | rec_end: i64, 54 | ) -> Result, Error>; 55 | 56 | /// update message read status by user id and message sequence 57 | async fn msg_read(&self, user_id: &str, msg_seq: &[i64]) -> Result<(), Error>; 58 | } 59 | 60 | pub trait MsgRecBoxCleaner: Sync + Send { 61 | /// run a task which use tokio to clean message receive box 62 | /// clean all messages except the messages that type is group operations related 63 | /// 64 | /// # Params 65 | /// * period: the period of time to clean, unit is day 66 | /// * types: the types of messages to not clean, such as group operations related; use MsgType 67 | /// 68 | fn clean_receive_box(&self, period: i64, types: Vec); 69 | } 70 | -------------------------------------------------------------------------------- /db/src/mongodb/mod.rs: -------------------------------------------------------------------------------- 1 | mod message; 2 | mod utils; 3 | 4 | pub(crate) use message::*; 5 | -------------------------------------------------------------------------------- /db/src/mongodb/utils.rs: -------------------------------------------------------------------------------- 1 | use abi::errors::Error; 2 | use abi::message::Msg; 3 | use bson::{Document, doc}; 4 | 5 | pub(crate) fn to_doc(msg: &Msg) -> Result { 6 | let document = doc! { 7 | "local_id": &msg.local_id, 8 | "server_id": &msg.server_id, 9 | "create_time": msg.create_time, 10 | "send_time": msg.send_time, 11 | "content_type": msg.content_type, 12 | "content": bson::Binary { subtype: bson::spec::BinarySubtype::Generic, bytes: msg.content.clone() }, 13 | "send_id": &msg.send_id, 14 | "receiver_id": &msg.receiver_id, 15 | "seq": msg.seq, 16 | "send_seq": msg.send_seq, 17 | "msg_type": msg.msg_type, 18 | "is_read": msg.is_read, 19 | "group_id": &msg.group_id, 20 | }; 21 | 22 | Ok(document) 23 | } 24 | -------------------------------------------------------------------------------- /db/src/postgres/message.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use sqlx::PgPool; 3 | 4 | use abi::errors::Error; 5 | use abi::message::Msg; 6 | 7 | use crate::message::MsgStoreRepo; 8 | 9 | #[derive(Debug)] 10 | pub struct PostgresMessage { 11 | pool: PgPool, 12 | } 13 | 14 | impl PostgresMessage { 15 | pub fn new(pool: PgPool) -> Self { 16 | Self { pool } 17 | } 18 | } 19 | 20 | #[async_trait] 21 | impl MsgStoreRepo for PostgresMessage { 22 | async fn save_message(&self, message: Msg) -> Result<(), Error> { 23 | sqlx::query( 24 | "INSERT INTO messages 25 | (local_id, server_id, send_id, receiver_id, msg_type, content_type, content, send_time, platform) 26 | VALUES 27 | ($1, $2, $3, $4, $5, $6, $7, $8, $9) 28 | ON CONFLICT DO NOTHING", 29 | ) 30 | .bind(&message.local_id) 31 | .bind(&message.server_id) 32 | .bind(&message.send_id) 33 | .bind(&message.receiver_id) 34 | .bind(message.msg_type) 35 | .bind(message.content_type) 36 | .bind(&message.content) 37 | .bind(message.send_time) 38 | .bind(message.platform) 39 | .execute(&self.pool) 40 | .await?; 41 | Ok(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /db/src/postgres/mod.rs: -------------------------------------------------------------------------------- 1 | mod friend; 2 | mod group; 3 | mod message; 4 | mod seq; 5 | mod user; 6 | 7 | pub(crate) use friend::*; 8 | pub(crate) use group::*; 9 | pub(crate) use message::*; 10 | pub(crate) use seq::*; 11 | pub(crate) use user::*; 12 | -------------------------------------------------------------------------------- /db/src/postgres/seq.rs: -------------------------------------------------------------------------------- 1 | use abi::errors::Error; 2 | use futures::TryStreamExt; 3 | use sqlx::{PgPool, Row}; 4 | use tokio::sync::mpsc::{self, Receiver}; 5 | use tonic::async_trait; 6 | use tracing::error; 7 | 8 | use crate::seq::SeqRepo; 9 | 10 | #[derive(Debug)] 11 | pub struct PostgresSeq { 12 | pool: PgPool, 13 | seq_step: i32, 14 | } 15 | 16 | impl PostgresSeq { 17 | pub fn new(pool: PgPool, seq_step: i32) -> Self { 18 | PostgresSeq { pool, seq_step } 19 | } 20 | } 21 | 22 | #[async_trait] 23 | impl SeqRepo for PostgresSeq { 24 | async fn save_send_max_seq(&self, user_id: &str) -> Result { 25 | let max_seq = sqlx::query( 26 | "UPDATE sequence SET send_max_seq = send_max_seq + $1 WHERE user_id = $2 RETURNING send_max_seq", 27 | ) 28 | .bind(self.seq_step) 29 | .bind(user_id) 30 | .fetch_one(&self.pool) 31 | .await? 32 | .try_get(0)?; 33 | Ok(max_seq) 34 | } 35 | 36 | async fn save_max_seq(&self, user_id: &str) -> Result { 37 | let max_seq = sqlx::query( 38 | "UPDATE sequence SET rec_max_seq = rec_max_seq + $1 WHERE user_id = $2 RETURNING rec_max_seq", 39 | ) 40 | .bind(self.seq_step) 41 | .bind(user_id) 42 | .fetch_one(&self.pool) 43 | .await? 44 | .try_get(0)?; 45 | Ok(max_seq) 46 | } 47 | 48 | /// update max_seq in batch 49 | async fn save_max_seq_batch(&self, user_ids: &[String]) -> Result<(), Error> { 50 | if user_ids.is_empty() { 51 | return Ok(()); 52 | } 53 | sqlx::query("UPDATE sequence SET rec_max_seq = rec_max_seq + $1 WHERE user_id = ANY($2)") 54 | .bind(self.seq_step) 55 | .bind(user_ids as &[_]) 56 | .execute(&self.pool) 57 | .await?; 58 | Ok(()) 59 | } 60 | 61 | async fn get_max_seq(&self) -> Result, Error> { 62 | let mut result = sqlx::query("SELECT user_id, send_max_seq, rec_max_seq FROM sequence") 63 | .fetch(&self.pool); 64 | let (tx, rx) = mpsc::channel(1024); 65 | while let Some(item) = result.try_next().await? { 66 | let user_id: String = item.try_get(0)?; 67 | let send_max_seq: i64 = item.try_get(1)?; 68 | let rec_max_seq: i64 = item.try_get(2)?; 69 | if let Err(e) = tx.send((user_id, send_max_seq, rec_max_seq)).await { 70 | error!("send error: {}", e); 71 | }; 72 | } 73 | Ok(rx) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /db/src/postgres/user.rs: -------------------------------------------------------------------------------- 1 | use argon2::{Argon2, PasswordHash, PasswordVerifier}; 2 | use async_trait::async_trait; 3 | use sqlx::PgPool; 4 | 5 | use abi::errors::Error; 6 | use abi::message::{User, UserUpdate, UserWithMatchType}; 7 | 8 | use crate::user::UserRepo; 9 | 10 | #[derive(Debug)] 11 | pub struct PostgresUser { 12 | pool: PgPool, 13 | init_max_seq: i32, 14 | } 15 | 16 | impl PostgresUser { 17 | pub fn new(pool: PgPool, init_max_seq: i32) -> Self { 18 | PostgresUser { pool, init_max_seq } 19 | } 20 | } 21 | 22 | #[async_trait] 23 | impl UserRepo for PostgresUser { 24 | async fn create_user(&self, user: User) -> Result { 25 | let now = chrono::Utc::now().timestamp_millis(); 26 | let mut tx = self.pool.begin().await?; 27 | let result = sqlx::query_as( 28 | "INSERT INTO users 29 | (id, name, account, password, avatar, gender, age, phone, email, address, region, salt, signature, create_time, update_time) 30 | VALUES 31 | ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *") 32 | .bind(&user.id) 33 | .bind(&user.name) 34 | .bind(&user.account) 35 | .bind(&user.password) 36 | .bind(&user.avatar) 37 | .bind(&user.gender) 38 | .bind(user.age) 39 | .bind(&user.phone) 40 | .bind(&user.email) 41 | .bind(&user.address) 42 | .bind(&user.region) 43 | .bind(&user.salt) 44 | .bind(&user.signature) 45 | .bind(now) 46 | .bind(now) 47 | .fetch_one(&mut *tx) 48 | .await?; 49 | 50 | // insert into sequence 51 | sqlx::query( 52 | "INSERT INTO sequence (user_id, send_max_seq, rec_max_seq) VALUES ($1, $2, $2)", 53 | ) 54 | .bind(&user.id) 55 | .bind(self.init_max_seq) 56 | .execute(&mut *tx) 57 | .await?; 58 | 59 | tx.commit().await?; 60 | Ok(result) 61 | } 62 | 63 | async fn get_user_by_id(&self, id: &str) -> Result, Error> { 64 | let user = sqlx::query_as("SELECT * FROM users WHERE id = $1") 65 | .bind(id) 66 | .fetch_optional(&self.pool) 67 | .await?; 68 | Ok(user) 69 | } 70 | 71 | async fn get_user_by_email(&self, email: &str) -> Result, Error> { 72 | let user = sqlx::query_as("SELECT * FROM users WHERE email = $1") 73 | .bind(email) 74 | .fetch_optional(&self.pool) 75 | .await?; 76 | Ok(user) 77 | } 78 | 79 | /// not allow to use username 80 | async fn search_user( 81 | &self, 82 | user_id: &str, 83 | pattern: &str, 84 | ) -> Result, Error> { 85 | let user = sqlx::query_as( 86 | "SELECT id, name, account, avatar, gender, age, email, region, birthday, signature, 87 | CASE 88 | WHEN phone = $2 THEN 'phone' 89 | WHEN email = $2 THEN 'email' 90 | WHEN account = $2 THEN 'account' 91 | ELSE null 92 | END AS match_type 93 | FROM users WHERE id <> $1 AND (email = $2 OR phone = $2 OR account = $2)", 94 | ) 95 | .bind(user_id) 96 | .bind(pattern) 97 | .fetch_optional(&self.pool) 98 | .await?; 99 | Ok(user) 100 | } 101 | 102 | async fn update_user(&self, user: UserUpdate) -> Result { 103 | let user = sqlx::query_as( 104 | "UPDATE users SET 105 | name = COALESCE(NULLIF($2, ''), name), 106 | avatar = COALESCE(NULLIF($3, ''), avatar), 107 | gender = COALESCE(NULLIF($4, ''), gender), 108 | phone = COALESCE(NULLIF($5, ''), phone), 109 | email = COALESCE(NULLIF($6, ''), email), 110 | address = COALESCE(NULLIF($7, ''), address), 111 | region = COALESCE(NULLIF($8, ''), region), 112 | birthday = COALESCE(NULLIF($9, 0), birthday), 113 | signature = COALESCE(NULLIF($10, ''), signature), 114 | update_time = $11 115 | WHERE id = $1 116 | RETURNING *", 117 | ) 118 | .bind(&user.id) 119 | .bind(&user.name) 120 | .bind(&user.avatar) 121 | .bind(&user.gender) 122 | .bind(&user.phone) 123 | .bind(&user.email) 124 | .bind(&user.address) 125 | .bind(&user.region) 126 | .bind(user.birthday) 127 | .bind(&user.signature) 128 | .bind(chrono::Utc::now().timestamp_millis()) 129 | .fetch_one(&self.pool) 130 | .await?; 131 | Ok(user) 132 | } 133 | 134 | async fn update_region(&self, user_id: &str, region: &str) -> Result<(), Error> { 135 | sqlx::query("UPDATE users SET region = $2 WHERE id = $1") 136 | .bind(user_id) 137 | .bind(region) 138 | .execute(&self.pool) 139 | .await?; 140 | Ok(()) 141 | } 142 | 143 | async fn verify_pwd(&self, account: &str, password: &str) -> Result, Error> { 144 | let user: Option = 145 | sqlx::query_as("SELECT * FROM users WHERE account = $1 OR phone = $1 OR email = $1") 146 | .bind(account) 147 | .fetch_optional(&self.pool) 148 | .await?; 149 | if user.is_none() { 150 | return Ok(None); 151 | } 152 | 153 | let mut user = user.unwrap(); 154 | let parsed_hash = PasswordHash::new(&user.password) 155 | .map_err(|e| Error::internal_with_details(e.to_string()))?; 156 | 157 | let is_valid = Argon2::default() 158 | .verify_password(password.as_bytes(), &parsed_hash) 159 | .is_ok(); 160 | user.password = "".to_string(); 161 | if !is_valid { 162 | return Ok(None); 163 | } 164 | Ok(Some(user)) 165 | } 166 | 167 | async fn modify_pwd(&self, user_id: &str, password: &str) -> Result<(), Error> { 168 | let mut user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1") 169 | .bind(user_id) 170 | .fetch_one(&__self.pool) 171 | .await?; 172 | if user.salt.is_empty() { 173 | user.salt = utils::generate_salt(); 174 | } 175 | 176 | let password = utils::hash_password(password.as_bytes(), &user.salt)?; 177 | sqlx::query("UPDATE users SET salt = $2, password = $3 WHERE id = $1") 178 | .bind(user_id) 179 | .bind(&user.salt) 180 | .bind(password) 181 | .execute(&self.pool) 182 | .await?; 183 | Ok(()) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /db/src/seq.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use tokio::sync::mpsc::Receiver; 4 | 5 | use abi::errors::Error; 6 | use tonic::async_trait; 7 | 8 | #[async_trait] 9 | pub trait SeqRepo: Sync + Send + Debug { 10 | async fn save_send_max_seq(&self, user_id: &str) -> Result; 11 | async fn save_max_seq(&self, user_id: &str) -> Result; 12 | async fn save_max_seq_batch(&self, user_ids: &[String]) -> Result<(), Error>; 13 | async fn get_max_seq(&self) -> Result, Error>; 14 | } 15 | -------------------------------------------------------------------------------- /db/src/user.rs: -------------------------------------------------------------------------------- 1 | use abi::errors::Error; 2 | use abi::message::{User, UserUpdate, UserWithMatchType}; 3 | use async_trait::async_trait; 4 | use std::fmt::Debug; 5 | 6 | #[async_trait] 7 | pub trait UserRepo: Sync + Send + Debug + Debug { 8 | /// create user 9 | async fn create_user(&self, user: User) -> Result; 10 | 11 | /// get user by id 12 | async fn get_user_by_id(&self, id: &str) -> Result, Error>; 13 | 14 | /// get user by email 15 | async fn get_user_by_email(&self, email: &str) -> Result, Error>; 16 | 17 | /// search user by pattern, return users and matched method 18 | async fn search_user( 19 | &self, 20 | user_id: &str, 21 | pattern: &str, 22 | ) -> Result, Error>; 23 | 24 | async fn update_user(&self, user: UserUpdate) -> Result; 25 | 26 | /// update user region by user id 27 | async fn update_region(&self, user_id: &str, region: &str) -> Result<(), Error>; 28 | 29 | async fn verify_pwd(&self, account: &str, password: &str) -> Result, Error>; 30 | 31 | async fn modify_pwd(&self, user_id: &str, password: &str) -> Result<(), Error>; 32 | } 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # networks: 2 | # sandcat-net: 3 | # driver: host 4 | services: 5 | # sandcat-backend: 6 | # build: . 7 | # container_name: sandcat-backend 8 | # ports: 9 | # - '50000:50000' # websocket 10 | # - '50001:50001' # server 11 | # - '50002:50002' # rpc.ws 12 | # - '50003:50003' # rpc.chat 13 | # - '50004:50004' # rpc.db 14 | # - '50005:50005' # rpc.pusher 15 | # volumes: 16 | # - './config-docker.yml:/usr/local/bin/config.yml' 17 | # depends_on: 18 | # kafka: 19 | # condition: service_healthy 20 | # synapse: 21 | # condition: service_started 22 | # redis: 23 | # condition: service_started 24 | # mongodb: 25 | # condition: service_started 26 | # postgres: 27 | # condition: service_started 28 | # minio: 29 | # condition: service_started 30 | # networks: 31 | # - sandcat-net 32 | 33 | kafka: 34 | image: 'bitnami/kafka:latest' 35 | environment: 36 | - KAFKA_CFG_NODE_ID=1 37 | - KAFKA_CFG_PROCESS_ROLES=broker,controller 38 | - KAFKA_CFG_LISTENERS=PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 39 | - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:9092 40 | - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@127.0.0.1:9093 41 | - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER 42 | - KAFKA_CFG_LOG_DIRS=/bitnami/kafka/data 43 | - ALLOW_PLAINTEXT_LISTENER=yes 44 | ports: 45 | - '9092:9092' 46 | volumes: 47 | - 'kafka_data:/bitnami/kafka/data' 48 | healthcheck: 49 | test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server kafka:9092 --list"] 50 | interval: 10s 51 | timeout: 5s 52 | retries: 5 53 | # networks: 54 | # - sandcat-net 55 | 56 | synapse: 57 | image: xuxiaomeng/synapse:latest 58 | container_name: synapse 59 | ports: 60 | - "8500:8500" 61 | environment: 62 | - SERVICE_ADDRESS=0.0.0.0:8500 63 | extra_hosts: 64 | - "host.docker.internal:host-gateway" 65 | # networks: 66 | # - sandcat-net 67 | 68 | redis: 69 | image: 'redis:latest' 70 | ports: 71 | - '6379:6379' 72 | # networks: 73 | # - sandcat-net 74 | 75 | mongodb: 76 | image: 'mongo:latest' 77 | ports: 78 | - '27017:27017' 79 | volumes: 80 | - 'mongodata:/data/db' 81 | # networks: 82 | # - sandcat-net 83 | 84 | postgres: 85 | image: 'postgres:latest' 86 | ports: 87 | - '5432:5432' 88 | environment: 89 | - POSTGRES_USER=postgres 90 | - POSTGRES_PASSWORD=postgres 91 | - POSTGRES_DB=im 92 | volumes: 93 | - 'postgres_data:/var/lib/postgresql/data' 94 | # networks: 95 | # - sandcat-net 96 | 97 | minio: 98 | image: 'minio/minio:latest' 99 | ports: 100 | - '9000:9000' # 将MinIO API端口映射到主机的9000端口 101 | - '9001:9001' # 将MinIO Console管理界面映射到主机的9001端口 102 | environment: 103 | MINIO_ROOT_USER: minioadmin # 最新的MinIO版本中推荐使用MINIO_ROOT_USER和MINIO_ROOT_PASSWORD 104 | MINIO_ROOT_PASSWORD: minioadmin 105 | command: server /data --console-address ":9001" # 添加控制台地址的映射 106 | volumes: 107 | - 'miniodata:/data' 108 | # networks: 109 | # - sandcat-net 110 | 111 | coturn: 112 | image: instrumentisto/coturn 113 | container_name: coturn 114 | ports: 115 | - "3478:3478" 116 | - "3478:3478/udp" 117 | - "60000-60100:60000-60100/udp" 118 | command: > 119 | -n --log-file=stdout 120 | --lt-cred-mech 121 | --realm=myrealm 122 | --server-name=myserver 123 | --no-multicast-peers 124 | --no-cli 125 | --no-tls # this for test only 126 | 127 | volumes: 128 | mongodata: 129 | miniodata: 130 | postgres_data: 131 | kafka_data: 132 | driver: local 133 | -------------------------------------------------------------------------------- /migrations/20240322014338_users.down.sql: -------------------------------------------------------------------------------- 1 | DROP table users;-- This file should undo anything in `up.sql` 2 | -------------------------------------------------------------------------------- /migrations/20240322014338_users.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users 2 | ( 3 | id VARCHAR PRIMARY KEY, 4 | name VARCHAR NOT NULL, 5 | account VARCHAR NOT NULL, 6 | password VARCHAR NOT NULL, 7 | salt VARCHAR NOT NULL, 8 | signature VARCHAR(1024), 9 | avatar VARCHAR NOT NULL, 10 | gender VARCHAR NOT NULL, 11 | age int NOT NULL DEFAULT 0, 12 | phone VARCHAR(20), 13 | email VARCHAR(64), 14 | address VARCHAR(1024), 15 | region VARCHAR(1024), 16 | birthday BIGINT, 17 | create_time BIGINT NOT NULL, 18 | update_time BIGINT NOT NULL, 19 | is_delete boolean NOT NULL DEFAULT FALSE 20 | ); 21 | -------------------------------------------------------------------------------- /migrations/20240322014354_friends.down.sql: -------------------------------------------------------------------------------- 1 | -- drop table friends;-- This file should undo anything in `up.sql` 2 | DROP TABLE friends; 3 | 4 | DROP TYPE friend_request_status; 5 | -------------------------------------------------------------------------------- /migrations/20240322014354_friends.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE friend_request_status AS ENUM( 2 | 'Pending', 3 | 'Accepted', 4 | 'Rejected', 5 | 'Blacked', 6 | 'Deleted' 7 | ); 8 | 9 | CREATE TABLE friends ( 10 | id BIGSERIAL primary key, 11 | fs_id VARCHAR NOT NULL, 12 | user_id VARCHAR NOT NULL, 13 | friend_id VARCHAR NOT NULL, 14 | status friend_request_status NOT NULL DEFAULT 'Accepted', 15 | remark VARCHAR, 16 | source VARCHAR, 17 | create_time BIGINT NOT NULL, 18 | update_time BIGINT NOT NULL, 19 | CONSTRAINT unique_user_friend UNIQUE (user_id, friend_id) 20 | ); 21 | 22 | CREATE INDEX idx_friends_fs_id ON friends (fs_id); 23 | -------------------------------------------------------------------------------- /migrations/20240322014403_friendships.down.sql: -------------------------------------------------------------------------------- 1 | drop table friendships; 2 | -------------------------------------------------------------------------------- /migrations/20240322014403_friendships.up.sql: -------------------------------------------------------------------------------- 1 | create table friendships 2 | ( 3 | id VARCHAR primary key, 4 | user_id VARCHAR NOT NULL, 5 | friend_id VARCHAR NOT NULL, 6 | status friend_request_status NOT NULL DEFAULT 'Pending', 7 | apply_msg VARCHAR, 8 | req_remark VARCHAR, 9 | resp_msg VARCHAR, 10 | resp_remark VARCHAR, 11 | source VARCHAR, 12 | create_time BIGINT NOT NULL, 13 | update_time BIGINT, 14 | Unique (user_id, friend_id) 15 | ) 16 | 17 | -- A 申请 B 为好友, 18 | -- 1. 向数据库插入一条数据; 19 | -- 2. 生成一条好友请求消息存入B的收件箱(mongodb) 20 | 21 | -- 2.1 B用户在线,通过websocket实时收到A好友请求消息, 22 | -- 2.2 B收到该消息,入库成功后向服务器返回已经收到该消息,并且弹出好友请求消息。 23 | -- 2.3 服务器收到B的收到消息,删除收件箱中的消息。 24 | -- (防止其他设备登陆时此次好友请求已经被处理,然后再次拉取到该消息,造成好友关系状态不一致; 25 | -- 其他设备同步好友关系不采取这种方式,存入收件箱的目的是为了方便主力设备能够第一时间对齐服务器消息) 26 | -- 27 | -- B同意该请求 28 | -- 1. 如果A拉黑B或者删除B,那么更新该数据的状态为已拉黑或者已删除。没有问题 29 | -- 2. 如果B拉黑A或者删除A,如何标记? 30 | -- 添加operator字段,用来标记操作者? 31 | 32 | -- 如果处于拉黑状态:A拉黑B 33 | -- 1. 恢复正常好友关系: 34 | -- A:只需取消拉黑 35 | -- 新增接口,恢复好友关系:因为可能存在B再次申请了好友请求,而此时A在B的好友信息页面操作恢复好友关系。 36 | -- B:需要重新申请好友关系, 37 | -- 更新好友请求状态为Pending, 38 | -- 问题: 如果在此期间B删除了A,该怎么办? 39 | -- 方案:1. 无法恢复好友关系,通知用户A,B已经删除了A,需要重新申请好友关系。 40 | -- 2. 41 | -- 2. 删除好友关系:A删除B。 42 | -- 修改该好友关系数据状态为已删除。并且更新update_time,用来其他设备上线时拉取该修改动作 43 | -- B收到删除操作的通知,标记为被删除,如果发送消息那么需要重新申请好友关系。 44 | -- A直接删除B,同时清理A客户端中的B好友信息以及聊天记录。 45 | -- 46 | 47 | -- 每个用户在操作已经达成好友关系的好友信息时,除了同步修改对方的好友关系状态,都只能修改自己的好友关系数据。 48 | -- 例如,A拉黑B,需要修改两条好友记录的状态。 49 | -- 但是如果是删除操作,那么需要修改B的好友关系数据状态为已删除同时删除A自己的好友关系数据。 50 | 51 | -- 其他设备登录,对齐好友关系(包括主力设备,因为用户可能交替使用不同的设备,当发生消息互发时,在线设备便是主力设备) 52 | -- 登录设备会保存离线时间,客户端在拉取好友关系数据时,会携带两个参数: 53 | -- { user_id , last_offline_time}服务器根据用户id以及上次离线时间获取好友关系数据 54 | -- 当该设备第一次登录时,last_offline_time为0,此时服务器会返回所有好友关系数据。 55 | -- 当该设备再次登录时,last_offline_time不为0,此时服务器会返回该设备上次离线时,好友关系数据更新后的数据。 56 | -- 数据库只保存最新的好友关系数据,并且根据update_time与last_offline_time进行对比, 57 | -- 如果update_time大于last_offline_time,则返回该数据。 58 | -- 同时,客户端需要对这些数据进行分类处理 59 | -- 1. 如果好友请求状态为Pending,并且本地状态为Pending,但是本地数据中的update_time与服务器返回的最新数据中的update_time不一致,那么更新本地数据并弹出消息通知; 60 | -- 1.2 如果本地没有数据,那么直接入库并弹出消息通知; 61 | -- 1.3 不存在本地数据与服务器返回的数据一致的情况 62 | -- 2. 如果好友请求状态为Accepted,并且本地数据状态为Pending,那么更新本地数据并弹出消息通知; 63 | -- 2.1 如果本地没有数据,那么直接入库,不需要其他处理(不需要拉取好友信息,会有单独的好友对齐操作) 64 | -- 3. 如果好友请求状态为Blacked,put数据库 65 | -- 4. 如果好友请求状态为Deleted,put数据库 66 | -- 》》总结:好像除了Pending状态,其他状态都可以直接put数据库, 67 | -- ***拉取好友列表*** 68 | -- 同样根据用户id以及上次离线时间拉取好友列表 69 | -------------------------------------------------------------------------------- /migrations/20240322014412_messages.down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DO 3 | $$ 4 | DECLARE 5 | partition RECORD; 6 | BEGIN 7 | FOR partition IN SELECT tablename FROM pg_catalog.pg_tables WHERE tablename LIKE 'messages%' 8 | LOOP 9 | EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(partition.tablename) || ' CASCADE;'; 10 | END LOOP; 11 | END 12 | $$; 13 | -------------------------------------------------------------------------------- /migrations/20240322014412_messages.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE messages 2 | ( 3 | send_id VARCHAR NOT NULL, 4 | receiver_id VARCHAR NOT NULL, 5 | local_id VARCHAR NOT NULL, 6 | server_id VARCHAR NOT NULL, 7 | send_time BIGINT NOT NULL, 8 | msg_type INT, 9 | content_type INT, 10 | content BYTEA, 11 | platform INT, 12 | PRIMARY KEY (send_id, server_id, send_time) 13 | ) PARTITION BY RANGE (send_time); 14 | 15 | DO $$ 16 | DECLARE 17 | week_start_ms BIGINT; 18 | week_end_ms BIGINT; 19 | current_week_start TIMESTAMP; 20 | year_week_num VARCHAR(10); 21 | BEGIN 22 | -- 获取当前时间戳所在周的开始时间(使用毫秒) 23 | current_week_start := DATE_TRUNC('week', NOW()); 24 | FOR i IN 0..51 LOOP 25 | -- 计算每个分区的开始和结束时间戳(毫秒) 26 | week_start_ms := (EXTRACT(EPOCH FROM current_week_start + (i * INTERVAL '1 week')) * 1000)::BIGINT; 27 | week_end_ms := (EXTRACT(EPOCH FROM current_week_start + ((i + 1) * INTERVAL '1 week')) * 1000)::BIGINT; 28 | 29 | -- 年份和周数标识 30 | year_week_num := TO_CHAR(current_week_start + (i * INTERVAL '1 week'), 'IYYY_IW'); 31 | 32 | -- 创建分区表 33 | EXECUTE 'CREATE TABLE IF NOT EXISTS messages_' || year_week_num || ' PARTITION OF messages FOR VALUES FROM (' || week_start_ms || ') TO (' || week_end_ms || ')'; 34 | END LOOP; 35 | END 36 | $$; 37 | -------------------------------------------------------------------------------- /migrations/20240322014422_groups.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS groups; 2 | -------------------------------------------------------------------------------- /migrations/20240322014422_groups.up.sql: -------------------------------------------------------------------------------- 1 | -- groups table creation sql 2 | -- do not use foreign key constraint 3 | CREATE TABLE groups 4 | ( 5 | id VARCHAR PRIMARY KEY, 6 | owner VARCHAR(256) NOT NULL, 7 | name VARCHAR(256) NOT NULL, 8 | avatar TEXT NOT NULL, 9 | description TEXT NOT NULL DEFAULT '', 10 | announcement TEXT NOT NULL DEFAULT '', 11 | create_time BIGINT NOT NULL, 12 | update_time BIGINT NOT NULL 13 | -- FOREIGN KEY (owner) REFERENCES users (id) 14 | ); 15 | -------------------------------------------------------------------------------- /migrations/20240322014431_group_members.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS group_members; 2 | 3 | DROP TYPE group_role; 4 | -------------------------------------------------------------------------------- /migrations/20240322014431_group_members.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE group_role AS ENUM('Owner', 'Admin', 'Member'); 2 | CREATE TABLE group_members 3 | ( 4 | -- id BIGSERIAL PRIMARY KEY, 5 | group_id VARCHAR NOT NULL, 6 | user_id VARCHAR NOT NULL, 7 | group_name VARCHAR(128), 8 | group_remark VARCHAR(128), 9 | role group_role NOT NULL DEFAULT 'Member', 10 | joined_at BIGINT NOT NULL, 11 | PRIMARY KEY (group_id, user_id) 12 | ); 13 | CREATE INDEX idx_group_members_group_id ON group_members (group_id); 14 | CREATE INDEX idx_group_members_user_id ON group_members (user_id); 15 | -------------------------------------------------------------------------------- /migrations/20240602023816_seq.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE sequence; 2 | -------------------------------------------------------------------------------- /migrations/20240602023816_seq.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE sequence ( 2 | user_id VARCHAR PRIMARY KEY, 3 | send_max_seq BIGINT NOT NULL DEFAULT 0, 4 | rec_max_seq BIGINT NOT NULL DEFAULT 0 5 | ); 6 | -------------------------------------------------------------------------------- /msg_gateway/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "msg_gateway" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | abi = { version = "0.1.0", path = "../abi" } 10 | cache = { version = "0.1.0", path = "../cache" } 11 | utils = { version = "0.1.0", path = "../utils" } 12 | 13 | anyhow = "1.0.81" 14 | axum = { version = "0.7.4", features = ["ws"] } 15 | bincode = "1.3.3" 16 | dashmap = "5.5.3" 17 | futures = "0.3.30" 18 | jsonwebtoken = "9" 19 | nanoid = "0.4.0" 20 | serde = "1.0.197" 21 | serde_json = "1.0.114" 22 | tokio = { version = "1.36.0", features = ["full"] } 23 | tracing = "0.1.40" 24 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 25 | tonic = { version = "0.11.0", features = ["gzip"] } 26 | 27 | synapse = { git = "https://github.com/Xu-Mj/synapse.git", branch = "main" } 28 | [dev-dependencies] 29 | tungstenite = "0.21.0" 30 | tokio-tungstenite = "0.21.0" 31 | url = "2.5.0" 32 | -------------------------------------------------------------------------------- /msg_gateway/src/client.rs: -------------------------------------------------------------------------------- 1 | use abi::message::PlatformType; 2 | use axum::extract::ws::{Message, WebSocket}; 3 | use futures::stream::SplitSink; 4 | use futures::SinkExt; 5 | use std::sync::Arc; 6 | use tokio::sync::mpsc::Sender; 7 | use tokio::sync::RwLock; 8 | 9 | type ClientSender = Arc>>; 10 | 11 | /// client 12 | #[derive(Debug)] 13 | pub struct Client { 14 | // hold a ws connection sender 15 | pub sender: ClientSender, 16 | // user id 17 | pub user_id: String, 18 | // platform id 19 | pub platform_id: String, 20 | pub platform: PlatformType, 21 | pub notify_sender: Sender<()>, 22 | } 23 | 24 | #[allow(dead_code)] 25 | impl Client { 26 | pub async fn send_text(&self, msg: String) -> Result<(), axum::Error> { 27 | self.sender.write().await.send(Message::Text(msg)).await 28 | } 29 | 30 | pub async fn send_binary(&self, msg: Vec) -> Result<(), axum::Error> { 31 | self.sender.write().await.send(Message::Binary(msg)).await 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /msg_gateway/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod manager; 3 | pub mod rpc; 4 | pub mod ws_server; 5 | -------------------------------------------------------------------------------- /msg_gateway/src/main.rs: -------------------------------------------------------------------------------- 1 | use tracing::Level; 2 | 3 | use abi::config::Config; 4 | use msg_gateway::ws_server::WsServer; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | tracing_subscriber::fmt() 9 | .with_max_level(Level::DEBUG) 10 | .init(); 11 | WsServer::start(Config::load("config.yml").unwrap()).await 12 | } 13 | #[cfg(test)] 14 | mod tests { 15 | use abi::message::msg_service_server::MsgServiceServer; 16 | use abi::message::Msg; 17 | use msg_gateway::rpc; 18 | use tonic::server::NamedService; 19 | 20 | #[test] 21 | fn test_load() { 22 | let msg = Msg::default(); 23 | println!("{}", serde_json::to_string(&msg).unwrap()); 24 | println!( 25 | "{:?}", 26 | as NamedService>::NAME 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /msg_gateway/src/rpc.rs: -------------------------------------------------------------------------------- 1 | use std::result::Result; 2 | 3 | use synapse::health::{HealthServer, HealthService}; 4 | use tonic::transport::Server; 5 | use tonic::{async_trait, Request, Response, Status}; 6 | use tracing::{debug, info}; 7 | 8 | use abi::config::{Component, Config}; 9 | use abi::errors::Error; 10 | use abi::message::msg_service_server::MsgServiceServer; 11 | use abi::message::{ 12 | msg_service_server::MsgService, SendGroupMsgRequest, SendMsgRequest, SendMsgResponse, 13 | }; 14 | 15 | use crate::manager::Manager; 16 | 17 | pub struct MsgRpcService { 18 | manager: Manager, 19 | } 20 | 21 | impl MsgRpcService { 22 | pub fn new(manager: Manager) -> Self { 23 | Self { manager } 24 | } 25 | 26 | pub async fn start(manager: Manager, config: &Config) -> Result<(), Error> { 27 | // register service to service register center 28 | utils::register_service(config, Component::MessageGateway).await?; 29 | info!(" rpc service register to service register center"); 30 | 31 | // open health check 32 | let health_service = HealthServer::new(HealthService::new()); 33 | info!(" rpc service health check started"); 34 | 35 | let service = Self::new(manager); 36 | let svc = MsgServiceServer::new(service); 37 | info!( 38 | " rpc service started at {}", 39 | config.rpc.ws.rpc_server_url() 40 | ); 41 | 42 | Server::builder() 43 | .add_service(health_service) 44 | .add_service(svc) 45 | .serve(config.rpc.ws.rpc_server_url().parse().unwrap()) 46 | .await 47 | .unwrap(); 48 | Ok(()) 49 | } 50 | } 51 | 52 | #[async_trait] 53 | impl MsgService for MsgRpcService { 54 | async fn send_message( 55 | &self, 56 | request: Request, 57 | ) -> Result, Status> { 58 | debug!("Got a request: {:?}", request); 59 | let msg = request 60 | .into_inner() 61 | .message 62 | .ok_or(Status::invalid_argument("message is empty"))?; 63 | self.manager.broadcast(msg).await?; 64 | let response = Response::new(SendMsgResponse {}); 65 | Ok(response) 66 | } 67 | 68 | /// Send message to user 69 | /// pusher will procedure this to send message to user 70 | async fn send_msg_to_user( 71 | &self, 72 | request: Request, 73 | ) -> Result, Status> { 74 | let msg = request 75 | .into_inner() 76 | .message 77 | .ok_or(Status::invalid_argument("message is empty"))?; 78 | debug!("send message to user: {:?}", msg); 79 | self.manager.send_single_msg(&msg.receiver_id, &msg).await; 80 | let response = Response::new(SendMsgResponse {}); 81 | Ok(response) 82 | } 83 | 84 | async fn send_group_msg_to_user( 85 | &self, 86 | request: Request, 87 | ) -> Result, Status> { 88 | let req = request.into_inner(); 89 | let msg = req 90 | .message 91 | .ok_or(Status::invalid_argument("message is empty"))?; 92 | let members = req.members; 93 | self.manager.send_group(members, msg).await; 94 | let response = Response::new(SendMsgResponse {}); 95 | Ok(response) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /msg_gateway/tests/rpc_test.rs: -------------------------------------------------------------------------------- 1 | /*use tokio::time; 2 | use tonic::transport::Channel; 3 | 4 | use abi::message::msg::Data; 5 | use abi::message::Msg; 6 | use abi::{ 7 | config::Config, 8 | message::{msg_service_client::MsgServiceClient, SendMsgRequest, Single}, 9 | }; 10 | use ws::ws_server::WsServer; 11 | 12 | #[tokio::test] 13 | async fn send_msg_should_work() { 14 | let config = Config::load("../abi/fixtures/im.yml").unwrap(); 15 | let mut client = get_client(&config).await; 16 | client 17 | .send_message(SendMsgRequest { 18 | message: Some(Msg { 19 | send_id: "".to_string(), 20 | receiver_id: "".to_string(), 21 | local_id: "".to_string(), 22 | server_id: "".to_string(), 23 | data: Some(Data::Single(Single { 24 | msg_id: "123".to_string(), 25 | content: "hello world".to_string(), 26 | content_type: 1, 27 | create_time: 123, 28 | })), 29 | }), 30 | }) 31 | .await 32 | .unwrap(); 33 | } 34 | 35 | // setup server 36 | fn setup_server(config: &Config) { 37 | let cloned_config = config.clone(); 38 | tokio::spawn(async move { 39 | WsServer::start(cloned_config).await; 40 | }); 41 | } 42 | 43 | // get client 44 | async fn get_client(config: &Config) -> MsgServiceClient { 45 | // start server at first 46 | setup_server(config); 47 | let url = config.server.url(false); 48 | 49 | println!("connect to {}", url); 50 | if let Err(err) = MsgServiceClient::connect(url.clone()).await { 51 | println!("err: {:?}", err); 52 | } 53 | // try to connect to server 54 | let future = async move { 55 | while MsgServiceClient::connect(url.clone()).await.is_err() { 56 | tokio::time::sleep(std::time::Duration::from_millis(1000)).await; 57 | } 58 | // return result 59 | MsgServiceClient::connect(url).await.unwrap() 60 | }; 61 | // set timeout 62 | time::timeout(time::Duration::from_secs(5), future) 63 | .await 64 | .unwrap_or_else(|e| panic!("connect timeout{:?}", e)) 65 | } 66 | */ 67 | -------------------------------------------------------------------------------- /msg_server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "msg_server" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | abi = { version = "0.1.0", path = "../abi" } 10 | cache = { version = "0.1.0", path = "../cache" } 11 | db = { version = "0.1.0", path = "../db" } 12 | utils = { version = "0.1.0", path = "../utils" } 13 | 14 | async-trait = "0.1.80" 15 | bincode = "1" 16 | chrono = { version = "0.4.31", features = ["serde"] } 17 | dashmap = "5.5.3" 18 | futures = "0.3.30" 19 | nanoid = "0.4.0" 20 | rdkafka = { version = "0.36.2" } 21 | serde = { version = "1.0", features = ["derive"] } 22 | serde_json = "1.0" 23 | tonic = { version = "0.11.0", features = ["gzip"] } 24 | tokio = { version = "1", features = ["full"] } 25 | tower = "0.4.13" 26 | tracing = "0.1" 27 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 28 | 29 | synapse = { git = "https://github.com/Xu-Mj/synapse.git", branch = "main" } 30 | 31 | 32 | [features] 33 | static = ["rdkafka/cmake-build"] 34 | dynamic = ["rdkafka/dynamic-linking"] 35 | -------------------------------------------------------------------------------- /msg_server/src/lib.rs: -------------------------------------------------------------------------------- 1 | use abi::config::Config; 2 | use consumer::ConsumerService; 3 | use productor::ChatRpcService; 4 | 5 | pub mod consumer; 6 | pub mod productor; 7 | mod pusher; 8 | 9 | pub async fn start(config: &Config) { 10 | let cloned_conf = config.clone(); 11 | let pro = tokio::spawn(async move { 12 | ChatRpcService::start(&cloned_conf).await; 13 | }); 14 | 15 | let cloned_conf = config.clone(); 16 | let con = tokio::spawn(async move { 17 | ConsumerService::new(&cloned_conf) 18 | .await 19 | .consume() 20 | .await 21 | .unwrap(); 22 | }); 23 | 24 | tokio::try_join!(pro, con).unwrap(); 25 | } 26 | -------------------------------------------------------------------------------- /msg_server/src/main.rs: -------------------------------------------------------------------------------- 1 | use tracing::Level; 2 | 3 | use abi::config::Config; 4 | 5 | use msg_server::productor::ChatRpcService; 6 | 7 | #[tokio::main] 8 | async fn main() { 9 | tracing_subscriber::fmt() 10 | .with_max_level(Level::DEBUG) 11 | .init(); 12 | let config = Config::load("config.yml").unwrap(); 13 | ChatRpcService::start(&config).await; 14 | } 15 | -------------------------------------------------------------------------------- /msg_server/src/productor.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use async_trait::async_trait; 4 | use nanoid::nanoid; 5 | use rdkafka::admin::{AdminClient, AdminOptions, NewTopic, TopicReplication}; 6 | use rdkafka::client::DefaultClientContext; 7 | use rdkafka::error::KafkaError; 8 | use rdkafka::producer::{FutureProducer, FutureRecord}; 9 | use rdkafka::ClientConfig; 10 | use synapse::health::{HealthServer, HealthService}; 11 | use tonic::transport::Server; 12 | use tracing::{error, info}; 13 | 14 | use abi::config::{Component, Config}; 15 | use abi::message::chat_service_server::{ChatService, ChatServiceServer}; 16 | use abi::message::{MsgResponse, MsgType, SendMsgRequest}; 17 | 18 | pub struct ChatRpcService { 19 | kafka: FutureProducer, 20 | topic: String, 21 | } 22 | 23 | impl ChatRpcService { 24 | pub fn new(kafka: FutureProducer, topic: String) -> Self { 25 | Self { kafka, topic } 26 | } 27 | pub async fn start(config: &Config) { 28 | let broker = config.kafka.hosts.join(","); 29 | let producer: FutureProducer = ClientConfig::new() 30 | .set("bootstrap.servers", &broker) 31 | .set( 32 | "message.timeout.ms", 33 | config.kafka.producer.timeout.to_string(), 34 | ) 35 | .set( 36 | "socket.timeout.ms", 37 | config.kafka.connect_timeout.to_string(), 38 | ) 39 | .set("acks", config.kafka.producer.acks.clone()) 40 | // make sure the message is sent exactly once 41 | .set("enable.idempotence", "true") 42 | .set("retries", config.kafka.producer.max_retry.to_string()) 43 | .set( 44 | "retry.backoff.ms", 45 | config.kafka.producer.retry_interval.to_string(), 46 | ) 47 | .create() 48 | .expect("Producer creation error"); 49 | 50 | Self::ensure_topic_exists(&config.kafka.topic, &broker, config.kafka.connect_timeout) 51 | .await 52 | .expect("Topic creation error"); 53 | 54 | // register service 55 | utils::register_service(config, Component::MessageServer) 56 | .await 57 | .expect("Service register error"); 58 | info!(" rpc service register to service register center"); 59 | 60 | // health check 61 | let health_service = HealthServer::new(HealthService::new()); 62 | info!(" rpc service health check started"); 63 | 64 | let chat_rpc = Self::new(producer, config.kafka.topic.clone()); 65 | let service = ChatServiceServer::new(chat_rpc); 66 | info!( 67 | " rpc service started at {}", 68 | config.rpc.chat.rpc_server_url() 69 | ); 70 | 71 | Server::builder() 72 | .add_service(health_service) 73 | .add_service(service) 74 | .serve(config.rpc.chat.rpc_server_url().parse().unwrap()) 75 | .await 76 | .unwrap(); 77 | } 78 | 79 | async fn ensure_topic_exists( 80 | topic_name: &str, 81 | brokers: &str, 82 | timeout: u16, 83 | ) -> Result<(), KafkaError> { 84 | // Create Kafka AdminClient 85 | let admin_client: AdminClient = ClientConfig::new() 86 | .set("bootstrap.servers", brokers) 87 | .set("socket.timeout.ms", timeout.to_string()) 88 | .create()?; 89 | 90 | // create topic 91 | let new_topics = [NewTopic { 92 | name: topic_name, 93 | num_partitions: 1, 94 | replication: TopicReplication::Fixed(1), 95 | config: vec![], 96 | }]; 97 | 98 | // fixme not find the way to check topic exist 99 | // so just create it and judge the error, 100 | // but don't find the error type for topic exist 101 | // and this way below can work well. 102 | let options = AdminOptions::new(); 103 | admin_client.create_topics(&new_topics, &options).await?; 104 | match admin_client.create_topics(&new_topics, &options).await { 105 | Ok(_) => { 106 | info!("Topic not exist; create '{}' ", topic_name); 107 | Ok(()) 108 | } 109 | Err(KafkaError::AdminOpCreation(_)) => { 110 | println!("Topic '{}' already exists.", topic_name); 111 | Ok(()) 112 | } 113 | Err(err) => Err(err), 114 | } 115 | } 116 | } 117 | 118 | #[async_trait] 119 | impl ChatService for ChatRpcService { 120 | /// send message to mq 121 | /// generate msg id and send time 122 | async fn send_msg( 123 | &self, 124 | request: tonic::Request, 125 | ) -> Result, tonic::Status> { 126 | let mut msg = request 127 | .into_inner() 128 | .message 129 | .ok_or(tonic::Status::invalid_argument("message is empty"))?; 130 | 131 | // generate msg id 132 | if !(msg.msg_type == MsgType::GroupDismissOrExitReceived as i32 133 | || msg.msg_type == MsgType::GroupInvitationReceived as i32 134 | || msg.msg_type == MsgType::FriendshipReceived as i32) 135 | { 136 | msg.server_id = nanoid!(); 137 | } 138 | msg.send_time = chrono::Utc::now().timestamp_millis(); 139 | 140 | // send msg to kafka 141 | let payload = serde_json::to_string(&msg).unwrap(); 142 | // let kafka generate key, then we need set FutureRecord 143 | let record: FutureRecord = FutureRecord::to(&self.topic).payload(&payload); 144 | 145 | info!("send msg to kafka: {:?}", record); 146 | let err = match self.kafka.send(record, Duration::from_secs(0)).await { 147 | Ok(_) => String::new(), 148 | Err((err, msg)) => { 149 | error!( 150 | "send msg to kafka error: {:?}; owned message: {:?}", 151 | err, msg 152 | ); 153 | err.to_string() 154 | } 155 | }; 156 | 157 | return Ok(tonic::Response::new(MsgResponse { 158 | local_id: msg.local_id, 159 | server_id: msg.server_id, 160 | send_time: msg.send_time, 161 | err, 162 | })); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /msg_server/src/pusher/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, sync::Arc}; 2 | 3 | use abi::{ 4 | config::Config, 5 | errors::Error, 6 | message::{GroupMemSeq, Msg}, 7 | }; 8 | use tonic::async_trait; 9 | 10 | mod service; 11 | 12 | #[async_trait] 13 | pub trait Pusher: Send + Sync + Debug { 14 | async fn push_single_msg(&self, msg: Msg) -> Result<(), Error>; 15 | async fn push_group_msg(&self, msg: Msg, members: Vec) -> Result<(), Error>; 16 | } 17 | 18 | pub async fn push_service(config: &Config) -> Arc { 19 | Arc::new(service::PusherService::new(config).await) 20 | } 21 | -------------------------------------------------------------------------------- /msg_server/src/pusher/service.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | use std::sync::Arc; 3 | use std::time::Duration; 4 | 5 | use abi::errors::Error; 6 | use async_trait::async_trait; 7 | use dashmap::DashMap; 8 | use synapse::service::client::ServiceClient; 9 | use synapse::service::Service; 10 | use tokio::sync::mpsc; 11 | use tonic::transport::{Channel, Endpoint}; 12 | use tower::discover::Change; 13 | use tracing::{debug, error}; 14 | 15 | use abi::config::Config; 16 | use abi::message::msg_service_client::MsgServiceClient; 17 | use abi::message::{GroupMemSeq, Msg, SendGroupMsgRequest, SendMsgRequest}; 18 | 19 | use super::Pusher; 20 | 21 | #[derive(Debug)] 22 | pub struct PusherService { 23 | ws_rpc_list: Arc>>, 24 | service_center: ServiceClient, 25 | sub_svr_name: String, 26 | } 27 | 28 | impl PusherService { 29 | pub async fn new(config: &Config) -> Self { 30 | let sub_svr_name = config.rpc.ws.name.clone(); 31 | let ws_rpc_list = Arc::new(DashMap::new()); 32 | let cloned_list = ws_rpc_list.clone(); 33 | let (tx, mut rx) = mpsc::channel::>(100); 34 | 35 | // read the service from the worker 36 | tokio::spawn(async move { 37 | while let Some(change) = rx.recv().await { 38 | debug!("receive service change: {:?}", change); 39 | match change { 40 | Change::Insert(service_id, client) => { 41 | match MsgServiceClient::connect(client).await { 42 | Ok(client) => { 43 | cloned_list.insert(service_id, client); 44 | } 45 | Err(err) => { 46 | error!("connect to ws service error: {:?}", err); 47 | } 48 | }; 49 | } 50 | Change::Remove(service_id) => { 51 | cloned_list.remove(&service_id); 52 | } 53 | } 54 | } 55 | }); 56 | 57 | utils::get_chan_(config, sub_svr_name.clone(), tx) 58 | .await 59 | .unwrap(); 60 | 61 | let service_center = ServiceClient::builder() 62 | .server_host(config.service_center.host.clone()) 63 | .server_port(config.service_center.port) 64 | .connect_timeout(Duration::from_millis(config.service_center.timeout)) 65 | .build() 66 | .await 67 | .unwrap(); 68 | Self { 69 | ws_rpc_list, 70 | service_center, 71 | sub_svr_name, 72 | } 73 | } 74 | 75 | pub async fn handle_sub_services(&self, services: Vec) { 76 | for service in services { 77 | let addr = format!("{}:{}", service.address, service.port); 78 | let socket: SocketAddr = match addr.parse() { 79 | Ok(sa) => sa, 80 | Err(err) => { 81 | error!("parse socket address error: {:?}", err); 82 | continue; 83 | } 84 | }; 85 | let addr = format!("{}://{}", service.scheme, addr); 86 | // connect to ws service 87 | let endpoint = match Endpoint::from_shared(addr) { 88 | Ok(ep) => ep.connect_timeout(Duration::from_secs(5)), 89 | Err(err) => { 90 | error!("connect to ws service error: {:?}", err); 91 | continue; 92 | } 93 | }; 94 | let ws = match MsgServiceClient::connect(endpoint).await { 95 | Ok(client) => client, 96 | Err(err) => { 97 | error!("connect to ws service error: {:?}", err); 98 | continue; 99 | } 100 | }; 101 | self.ws_rpc_list.insert(socket, ws); 102 | } 103 | } 104 | } 105 | 106 | #[async_trait] 107 | impl Pusher for PusherService { 108 | async fn push_single_msg(&self, request: Msg) -> Result<(), Error> { 109 | debug!("push msg request: {:?}", request); 110 | 111 | let ws_rpc = self.ws_rpc_list.clone(); 112 | if ws_rpc.is_empty() { 113 | let mut client = self.service_center.clone(); 114 | let list = client 115 | .query_with_name(self.sub_svr_name.clone()) 116 | .await 117 | .map_err(|e| Error::internal_with_details(e.to_string()))?; 118 | self.handle_sub_services(list).await; 119 | } 120 | 121 | let request = SendMsgRequest { 122 | message: Some(request), 123 | }; 124 | let (tx, mut rx) = mpsc::channel(ws_rpc.len()); 125 | 126 | // send message to ws with asynchronous way 127 | for v in ws_rpc.iter() { 128 | let tx = tx.clone(); 129 | let service_id = *v.key(); 130 | let mut v = v.clone(); 131 | let request = request.clone(); 132 | tokio::spawn(async move { 133 | if let Err(err) = v.send_msg_to_user(request).await { 134 | tx.send((service_id, err)).await.unwrap(); 135 | }; 136 | }); 137 | } 138 | 139 | // close tx 140 | drop(tx); 141 | 142 | // todo need to update client list; and need to handle error 143 | while let Some((service_id, err)) = rx.recv().await { 144 | ws_rpc.remove(&service_id); 145 | error!("push msg to {} failed: {}", service_id, err); 146 | } 147 | Ok(()) 148 | } 149 | 150 | async fn push_group_msg(&self, msg: Msg, members: Vec) -> Result<(), Error> { 151 | debug!("push group msg request: {:?}, {:?}", msg, members); 152 | // extract request 153 | let ws_rpc = self.ws_rpc_list.clone(); 154 | if ws_rpc.is_empty() { 155 | let mut client = self.service_center.clone(); 156 | let list = client 157 | .query_with_name(self.sub_svr_name.clone()) 158 | .await 159 | .map_err(|e| Error::internal_with_details(e.to_string()))?; 160 | self.handle_sub_services(list).await; 161 | } 162 | 163 | let request = SendGroupMsgRequest { 164 | message: Some(msg), 165 | members, 166 | }; 167 | let (tx, mut rx) = mpsc::channel(ws_rpc.len()); 168 | // send message to ws with asynchronous way 169 | for v in ws_rpc.iter() { 170 | let tx = tx.clone(); 171 | let service_id = *v.key(); 172 | let mut v = v.clone(); 173 | let request = request.clone(); 174 | tokio::spawn(async move { 175 | match v.send_group_msg_to_user(request).await { 176 | Ok(_) => { 177 | tx.send(Ok(())).await.unwrap(); 178 | } 179 | Err(err) => { 180 | tx.send(Err((service_id, err))).await.unwrap(); 181 | } 182 | }; 183 | }); 184 | } 185 | // close tx 186 | drop(tx); 187 | // todo need to update client list 188 | while let Some(Err((service_id, err))) = rx.recv().await { 189 | ws_rpc.remove(&service_id); 190 | error!("push msg to {} failed: {}", service_id, err); 191 | } 192 | Ok(()) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /oss/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "oss" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | abi = { version = "0.1.0", path = "../abi" } 10 | 11 | async-trait = "0.1.79" 12 | aws-config = { version = "1.0.1", features = ["behavior-version-latest"] } 13 | aws-sdk-s3 = { version = "1.4.0", features = ["rt-tokio"] } 14 | aws-smithy-runtime = { version = "1.0.1" } 15 | aws-smithy-runtime-api = { version = "1.0.1", features = ["client"] } 16 | aws-smithy-types = { version = "1.0.1", features = ["http-body-0-4-x"] } 17 | bytes = "1.6.0" 18 | http = "1.1.0" 19 | http-body = "1.0.0" 20 | md-5 = "0.10.1" 21 | pin-project = "1.0.12" 22 | rand = "0.8.5" 23 | serde = { version = "1", features = ["derive"]} 24 | serde_json = "1" 25 | thiserror = "1.0" 26 | tokio = { version = "1.20.1", features = ["full"] } 27 | tokio-stream = "0.1.8" 28 | tracing = "0.1.37" 29 | tracing-subscriber = { version = "0.3.5", features = ["env-filter"] } 30 | uuid = { version = "1.3.1", features = ["serde", "v4"] } 31 | -------------------------------------------------------------------------------- /oss/default_avatar/avatar1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/oss/default_avatar/avatar1.png -------------------------------------------------------------------------------- /oss/default_avatar/avatar2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/oss/default_avatar/avatar2.png -------------------------------------------------------------------------------- /oss/default_avatar/avatar3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/oss/default_avatar/avatar3.png -------------------------------------------------------------------------------- /oss/src/client.rs: -------------------------------------------------------------------------------- 1 | use abi::config::Config; 2 | use abi::errors::Error; 3 | use async_trait::async_trait; 4 | use aws_sdk_s3::Client; 5 | use aws_sdk_s3::config::{Builder, Credentials, Region}; 6 | use aws_smithy_runtime_api::client::result::SdkError; 7 | use bytes::Bytes; 8 | use tokio::fs; 9 | use tracing::error; 10 | 11 | use crate::{Oss, default_avatars}; 12 | 13 | #[derive(Debug, Clone)] 14 | pub(crate) struct S3Client { 15 | bucket: String, 16 | avatar_bucket: String, 17 | client: Client, 18 | } 19 | 20 | impl S3Client { 21 | pub async fn new(config: &Config) -> Self { 22 | let credentials = Credentials::new( 23 | &config.oss.access_key, 24 | &config.oss.secret_key, 25 | None, 26 | None, 27 | "MinioCredentials", 28 | ); 29 | 30 | let bucket = config.oss.bucket.clone(); 31 | let avatar_bucket = config.oss.avatar_bucket.clone(); 32 | 33 | let config = Builder::new() 34 | .region(Region::new(config.oss.region.clone())) 35 | .credentials_provider(credentials) 36 | .endpoint_url(&config.oss.endpoint) 37 | // use latest behavior version, have to set it manually, 38 | // although we turn on the feature 39 | .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest()) 40 | .build(); 41 | 42 | let client = Client::from_conf(config); 43 | 44 | let self_ = Self { 45 | client, 46 | bucket, 47 | avatar_bucket, 48 | }; 49 | 50 | self_.create_bucket().await.unwrap(); 51 | self_.check_default_avatars().await.unwrap(); 52 | self_ 53 | } 54 | 55 | async fn check_bucket_exists(&self) -> Result { 56 | match self.client.head_bucket().bucket(&self.bucket).send().await { 57 | Ok(_response) => Ok(true), 58 | Err(SdkError::ServiceError(e)) => { 59 | if e.raw().status().as_u16() == 404 { 60 | Ok(false) 61 | } else { 62 | Err(Error::internal_with_details( 63 | "check avatar_bucket exists error", 64 | )) 65 | } 66 | } 67 | Err(e) => { 68 | error!("check_bucket_exists error: {:?}", e); 69 | Err(Error::internal_with_details(e.to_string())) 70 | } 71 | } 72 | } 73 | 74 | async fn check_avatar_bucket_exits(&self) -> Result { 75 | match self 76 | .client 77 | .head_bucket() 78 | .bucket(&self.avatar_bucket) 79 | .send() 80 | .await 81 | { 82 | Ok(_response) => Ok(true), 83 | Err(SdkError::ServiceError(e)) => { 84 | if e.raw().status().as_u16() == 404 { 85 | Ok(false) 86 | } else { 87 | Err(Error::internal_with_details( 88 | "check avatar_bucket exists error", 89 | )) 90 | } 91 | } 92 | Err(e) => { 93 | error!("check avatar_bucket exists error: {:?}", e); 94 | Err(Error::internal_with_details(e.to_string())) 95 | } 96 | } 97 | } 98 | 99 | async fn check_default_avatars(&self) -> Result<(), Error> { 100 | for (path, name) in default_avatars().into_iter() { 101 | if !self.exists_by_name(&self.avatar_bucket, &name).await { 102 | if let Ok(data) = fs::read(&path).await { 103 | self.upload_avatar(&name, data).await?; 104 | } 105 | } 106 | } 107 | Ok(()) 108 | } 109 | 110 | async fn create_bucket(&self) -> Result<(), Error> { 111 | let is_exist = self.check_bucket_exists().await?; 112 | if !is_exist { 113 | self.client 114 | .create_bucket() 115 | .bucket(&self.bucket) 116 | .send() 117 | .await?; 118 | } 119 | 120 | if !self.check_avatar_bucket_exits().await? { 121 | self.client 122 | .create_bucket() 123 | .bucket(&self.avatar_bucket) 124 | .send() 125 | .await?; 126 | } 127 | Ok(()) 128 | } 129 | } 130 | 131 | #[async_trait] 132 | impl Oss for S3Client { 133 | async fn file_exists(&self, key: &str, _local_md5: &str) -> Result { 134 | Ok(self.exists_by_name(&self.bucket, key).await) 135 | } 136 | 137 | async fn upload_file(&self, key: &str, content: Vec) -> Result<(), Error> { 138 | self.upload(&self.bucket, key, content).await 139 | } 140 | 141 | async fn download_file(&self, key: &str) -> Result { 142 | self.download(&self.bucket, key).await 143 | } 144 | 145 | async fn delete_file(&self, key: &str) -> Result<(), Error> { 146 | self.delete(&self.bucket, key).await 147 | } 148 | 149 | async fn upload_avatar(&self, key: &str, content: Vec) -> Result<(), Error> { 150 | self.upload(&self.avatar_bucket, key, content).await 151 | } 152 | async fn download_avatar(&self, key: &str) -> Result { 153 | self.download(&self.avatar_bucket, key).await 154 | } 155 | async fn delete_avatar(&self, key: &str) -> Result<(), Error> { 156 | self.delete(&self.avatar_bucket, key).await 157 | } 158 | } 159 | 160 | impl S3Client { 161 | async fn exists_by_name(&self, bucket: &str, key: &str) -> bool { 162 | self.client 163 | .head_object() 164 | .bucket(bucket) 165 | .key(key) 166 | .send() 167 | .await 168 | .is_ok() 169 | // match self 170 | // .client 171 | // .head_object() 172 | // .bucket(bucket) 173 | // .key(key) 174 | // .send() 175 | // .await 176 | // { 177 | // Ok(_resp) => { 178 | // /* if let Some(etag) = resp.e_tag() { 179 | // // remove the double quotes 180 | // let etag = etag.trim_matches('"'); 181 | // Ok(etag == local_md5) 182 | // } else { 183 | // Ok(false) 184 | // } */ 185 | // true 186 | // } 187 | // Err(_) => false, 188 | // } 189 | } 190 | 191 | async fn upload(&self, bucket: &str, key: &str, content: Vec) -> Result<(), Error> { 192 | self.client 193 | .put_object() 194 | .bucket(bucket) 195 | .key(key) 196 | .body(content.into()) 197 | .send() 198 | .await?; 199 | Ok(()) 200 | } 201 | 202 | async fn download(&self, bucket: &str, key: &str) -> Result { 203 | let resp = self 204 | .client 205 | .get_object() 206 | .bucket(bucket) 207 | .key(key) 208 | .send() 209 | .await?; 210 | 211 | let data = resp.body.collect().await.map_err(Error::internal)?; 212 | 213 | Ok(data.into_bytes()) 214 | } 215 | 216 | async fn delete(&self, bucket: &str, key: &str) -> Result<(), Error> { 217 | let client = self.client.clone(); 218 | client 219 | .delete_object() 220 | .bucket(bucket) 221 | .key(key) 222 | .send() 223 | .await?; 224 | 225 | Ok(()) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /oss/src/lib.rs: -------------------------------------------------------------------------------- 1 | use abi::config::Config; 2 | use abi::errors::Error; 3 | use async_trait::async_trait; 4 | use bytes::Bytes; 5 | use std::collections::HashMap; 6 | use std::fmt::Debug; 7 | use std::sync::Arc; 8 | 9 | mod client; 10 | 11 | #[async_trait] 12 | pub trait Oss: Debug + Send + Sync { 13 | async fn file_exists(&self, key: &str, local_md5: &str) -> Result; 14 | async fn upload_file(&self, key: &str, content: Vec) -> Result<(), Error>; 15 | async fn download_file(&self, key: &str) -> Result; 16 | async fn delete_file(&self, key: &str) -> Result<(), Error>; 17 | 18 | async fn upload_avatar(&self, key: &str, content: Vec) -> Result<(), Error>; 19 | async fn download_avatar(&self, key: &str) -> Result; 20 | async fn delete_avatar(&self, key: &str) -> Result<(), Error>; 21 | } 22 | 23 | pub async fn oss(config: &Config) -> Arc { 24 | Arc::new(client::S3Client::new(config).await) 25 | } 26 | 27 | pub fn default_avatars() -> HashMap { 28 | HashMap::from([ 29 | ( 30 | String::from("./oss/default_avatar/avatar1.png"), 31 | String::from("avatar1.png"), 32 | ), 33 | ( 34 | String::from("./oss/default_avatar/avatar2.png"), 35 | String::from("avatar2.png"), 36 | ), 37 | ( 38 | String::from("./oss/default_avatar/avatar3.png"), 39 | String::from("avatar3.png"), 40 | ), 41 | ]) 42 | } 43 | -------------------------------------------------------------------------------- /oss/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("Hello, world!"); 3 | } 4 | -------------------------------------------------------------------------------- /rfcs/core_flow.md: -------------------------------------------------------------------------------- 1 | 项目的整体架构采用了微服务架构,将系统拆分成多个独立的服务单元,每个服务单元负责核心业务逻辑的一部分,以及与其他服务的通信。 2 | 服务之间的通讯采用了gRPC协议。 3 | ![Core Flow](./images/seq-chart.png) 4 | 接下来,我将详细阐述从客户端a向客户端b发送一条消息的完整架构流程: 5 | 6 | 1. 客户端a向消息服务发送一条消息,消息服务将消息发送到chat服务。 7 | 2. chat服务生成server_id以及send_time并将消息发布到kafka消息队列。 8 | 3. chat服务将发布结果返回给websocket服务,发布成功即代表消息发送成功 9 | 4. websocket服务返回一个发送结果给客户端a 10 | 5. consumer服务从kafka消息队列中获取消息,根据用户id增加用户缓存(redis)中sequence。 11 | 将消息存储到postgres中作为历史记录;存储到mongodb中作为收件箱消息, 12 | 6. 同时consumer服务将消息发送到pusher服务, 13 | 7. pusher服务会将消息发送到所有的websocket服务, 14 | 8. websocket服务根据receiver_id将消息发送到客户端b。 15 | 16 | 我们采用kafka作为消息的主队列,实现了异步处理,提高了系统输入和输出的速度。同时,微服务架构使得整个系统更易于扩展和维护。 17 | **整个架构主要分为一下几大模块:** 18 | 19 | 1. **服务层** 20 | - **认证服务:** 处理用户的注册、登录和验证。 21 | - **消息服务:** 负责消息(单聊、群聊)的发送、接收和转发。 22 | - **好友服务:** 管理用户的好友列表和状态。 23 | - **群组服务:** 管理群组的创建和成员管理。 24 | 25 | 2. **数据存储层** 26 | - **PostgreSQL:** 负责存储用户信息、好友关系和消息历史,结合定时任务实现数据的自动归档。 27 | - **MongoDB:** 用作消息的收件箱,处理离线消息的存储和查询。 28 | 29 | 3. **中间件层** 30 | - **Kafka:** 提供高吞吐量的消息队列,使服务之间解耦。 31 | - **Redis:** 实现缓存和消息状态的维护,优化数据库加载。 32 | 33 | 4. **基础设施层** 34 | - **Docker和Docker-Compose:** 用于构建和部署服务的容器。 35 | - **Consul:** 服务的注册与发现。 36 | - **MinIO:** 对象存储解决方案,用于文件上传和下载。 37 | 38 | 39 | 40 | ### 消息服务 41 | 1. ws服务:采用websocket协议,用来与客户端保持连接,同时能够实现服务端主动推送消息到客户端,基于axum的websocket实现。 42 | 43 | 1. 管理用户的连接状态,维护用户的连接池。这里通过一个Map来维护用户的连接状态,key为用户的id,value同样也是一个Map,子Map的key是用户的指纹id(浏览器id或者机器id),value为用户的连接;这样设计的目的是能够使得用户能够多端同时在线,( 但是目前暂时没有实现多端消息发送的同步, 同平台在线状态踢出也暂时没有实现)。 44 | 2. 将用户发送过来的消息通过gRPC发送到chat服务,chat服务将消息发送到kafka消息队列,写入消息队列即代表消息发送成功,返回给用户发送成功的消息。 45 | 3. 将消息推送给在线的用户,不在线的直接丢弃 46 | 2. chat服务: 接收ws服务发送过来的消息,生成server_id和send_time,将消息写入kafka消息队列。 47 | 3. consumer服务: 48 | 1. 从kafka消息队列中获取消息, 49 | 2. 增加用户缓存(redis)中sequence。 50 | 3. 将消息通过gRPC发送到db服务。 51 | 4. 将消息发送到pusher服务, 52 | 这里3、4是使用两个tokio的task并行处理的。(这块的设计感觉还存在一些问题,都还没来得及解决;比如:这里存在着两个网络IO的调用,是存在发送失败的风险的,这里还没有对发送失败做很好的处理) 53 | 4. db服务: 54 | 1. 将消息存储到postgres中作为历史记录; 55 | 2. 将消息存储到mongodb中作为收件箱消息, 56 | 3. 为认证服务、好友服务、群聊服务提供数据存储的接口。 57 | 4. 为用户拉取离线消息提供接口。 58 | 5. pusher服务:将消息发送到所有的websocket服务,这里是直接发送给所有的ws服务(因为考虑到规模肯定不大,一个websocket能够处理几万个连接,所以这里就直接发送给所有的ws服务了)。 59 | 60 | ### 认证服务、好友服务、群聊服务被统一封装到api服务中,采用http协议对外提供服务。 61 | 1. 用户接口:注册、登录、修改用户信息、查找用户信息 62 | 2. 好友接口:添加好友、删除好友、拉取好友列表 63 | 3. 群聊接口:创建群聊、拉取群聊成员、修改群聊信息、邀请好友加入群聊、退出群聊、解散群聊 64 | 4. 消息接口:拉取离线消息 65 | 5. 文件接口:上传文件、下载文件。采用了minio作为文件存储服务,基于aws_sdk_s3实现。 66 | 67 | ### 数据库设计 68 | 1. postgres:用户表、好友表、消息表、群聊表、群成员表 69 | 2. mongodb:用户消息表 70 | 这里消息的历史记录都存到了postgres中的同一张表中,使用分库分表的方式来降低单表的数据量。同时postgres中的消息数据是不会变动的。 71 | 消息的收件箱存到了mongodb中,这里的设计是为了能够更好的支持离线消息的存储和查询。 72 | 73 | ### 缓存 74 | 缓存使用了redis, 75 | 1. 用户的sequence存储在redis中,用来保证消息的有序性。 76 | 2. 在线用户id状态也存储在redis中(暂时没用)。 77 | 3. 群聊的群成员id存储在redis中,用来减少处理群聊消息时数据库的查询次数。 78 | -------------------------------------------------------------------------------- /rfcs/draft.md: -------------------------------------------------------------------------------- 1 | - 鉴权部分使用了jwt, 2 | 考虑到im应用用户大部分时间都是在通过websocket进行通信,而这部分时间没必要重复鉴权,也没必要在websocket通信过程中进行鉴权。因此我们采取一种客户端静谧续约的方式:客户端在token过期之前向服务器发送一个续约请求,服务器收到续约请求后会返回一个新的token,客户端使用新的token继续通信。这样可以保证token的有效性,也不会影响用户的体验。 3 | -------------------------------------------------------------------------------- /rfcs/greet.md: -------------------------------------------------------------------------------- 1 | # 基于Rust打造一个分布式可伸缩部署的IM应用 2 | 3 | 在当今数字化时代,即时通讯(Instant Messaging,IM)应用已经成为日常生活和工作中不可或缺的沟通方式。面对海量用户的需求和不断变化的业务场景,如何打造一个高效、安全且能够轻松应对流量高峰的IM应用成为了开发者面临的一大挑战。本项目致力于开发一款基于Rust语言的分布式IM应用,以其出色的性能、安全性和内存安全的特性,实现了一个高可靠性和可伸缩性的前后端解决方案。 4 | 5 | 得益于Rust的零成本抽象和无数据竞争的并发模型,我们的IM应用在保证高性能的同时,极大地提高了系统的稳定性和安全性。后端系统采用了微服务架构,从而使功能模块之间的解耦更加彻底,每一部分都可以独立部署和扩展,以应对不断增长的服务需求和复杂的业务逻辑。 6 | 7 | 通过分布式设计,我们将核心的IM功能如消息传递、状态同步、用户管理等划分为单独的服务单元,每个单元都可以在多个服务器上横向扩展,实现真正的分布式处理。这样的部署方式不仅提升了整体服务的可用性和容错性,也极大地方便了后续的系统升级和维护。 8 | 9 | 在前端,我们同样选择Rust开发,并通过WebAssembly将Rust代码运行在浏览器中,以此带来原生级别的性能和快速的执行速度。 10 | 11 | ## 技术选型 12 | 13 | #### 后端技术选型 14 | 15 | 我们后端服务的构建充分利用了Rust的生态系统,主要采用以下技术和框架: 16 | 17 | - **Axum**:一个高效的web框架,提供了强大的抽象,支持构建高性能的异步应用。 18 | - **Tonic**与**gRPC**:用于创建高性能的RPC服务,实现服务间高效通信。 19 | - **Kafka**:作为分布式消息队列,kafka处理大规模数据流同时保持高吞吐率。 20 | - **PostgreSQL**与**SQLx**:我们选择了PostgreSQL作为关系型数据库,搭配SQLx实现异步数据库操作。 21 | - **MongoDB**:作为非关系型数据库,用以处理更灵活的数据结构,及收件箱等特定功能的数据存储。 22 | - **MinIO**:一种高性能的对象存储解决方案,用于存储用户的文件和媒体资料。 23 | - **Redis**:作为缓存和消息中间件,实现快速数据读取和状态管理。 24 | - **Consul**:提供服务发现和配置,保持微服务的高可用性和弹性。 25 | 26 | #### 前端技术选型 27 | 28 | 在前端,我们选择了**Yew**框架,这是一个基于Rust的前端框架,可以编译成WebAssembly,从而在浏览器中提供接近原生的运行速度和更好的性能表现。 29 | 30 | ## 产品最终效果图 31 | 32 | - 登录页面 33 | 34 | ![登录-英文](./images/image-login.png) 35 | 36 | - 注册页面 37 | 38 | ![image-register](./images/register.jpg) 39 | 40 | - 聊天页面 41 | 42 | ![home](./images/home.jpg) 43 | 44 | - 联系人页面 45 | 46 | ![contacts](./images/contacts.jpg) 47 | 48 | ## 技术实现 49 | 50 | 目前软件主要实现了基本的IM应用的功能,包括基础的好友系统、单聊、群聊、单聊音视频通话。同时支持了i18n目前支持中英文切换。 51 | 52 | ### 单聊/群聊 53 | 54 | 通过WebSocket协议实现了实时的单聊和群聊消息功能。消息在接收后,根据其类型进行相应的处理流程。以下是主要特点及实现细节: 55 | 56 | 1. **实时消息传输**: 57 | - 所有的即时通讯交互均基于WebSocket协议完成,保证了高效的双向通信和较低的延迟。 58 | 2. **消息序列验证**: 59 | - 每条消息都有一个与之关联的顺序标识(sequence)。客户端会依据此sequence检查是否存在漏收的消息。 60 | - 若检测到消息缺失,系统将通过比较本地和服务器端的sequence记录,进行必要的消息补全操作。 61 | 3. **消息存储机制**: 62 | - 处理完毕的消息会被安全地保存至IndexedDB数据库中,方便用户之后的查询和查看,实现本地持久化存储。 63 | 4. **断线重连策略**: 64 | - 系统内置了断线重连机制,当WebSocket连接意外断开时,能够自动尝试重新建立连接,确保用户体验的连续性。 65 | 5. **前端技术实现**: 66 | - 相较于后端复杂的架构体系,前端实现侧重于运用Rust的特性,诸如闭包、回调函数以及生命周期管理等知识点,以实现轻量且高效的客户端通信。 67 | 68 | 通过综合运用这些策略和技术,单聊和群聊消息系统旨在提供连续无缝的通信体验。 69 | 70 | ### 好友系统 71 | 72 | ​ 结合了HTTP请求与WebSocket通信,以确保功能的高效性和实时性。好友系统主要涉及以下功能模块: 73 | 74 | 1. **搜索好友功能**: 75 | - 用户可以通过手机号、邮箱地址或用户账号进行好友搜索。 76 | - 搜索操作基于HTTP协议实现,仅支持精确匹配方式。 77 | 2. **添加好友请求**: 78 | - 用户在获取搜索结果后,填写必要的申请信息,通过HTTP请求向服务器发起添加好友的操作。 79 | - 服务器接收到添加请求后,会利用WebSocket实时转发请求信息至被请求用户,确保及时通知。 80 | - 请求数据同时被记录在数据库中,且转存入被请求用户的收件箱,以便在用户离线时依旧能够接收到好友请求消息。 81 | 3. **处理好友请求**: 82 | - 被请求用户在线时,或在下次登录时,可以查看并处理好友请求。 83 | - 在线用户处理好友请求动作(如同意请求)同样会通过HTTP请求发送至服务器。 84 | - 服务器将处理结果通过WebSocket即时通知至请求发起方,并且记录在数据库与请求发起方的收件箱。这一步骤保证了即便请求发起方当前不在线,也能在上线后得到通知。 85 | 86 | 通过上述设计,好友系统在实现基础功能的同时,保证了用户交互的即时响应和数据一致性。好友系统目前的设计很脆弱,功能非常有限,会在将来进行重构。 87 | 88 | ### 群聊 89 | 90 | 群聊主要包括创建群聊、邀请新的成员加入、退出群聊以及解散群聊,对于客户端的逻辑相对简单, 91 | 92 | #### 创建群聊 93 | 94 | 用户可以轻松创建一个群聊,并邀请他们的好友加入。这一过程的实现步骤如下: 95 | 96 | - 用户在应用中选择需要邀请的好友,随后点击“创建群聊”按钮。这时,客户端会搜集并发送邀请的群聊成员ID、头像以及其他必要信息给服务端。 97 | - 服务端处理这些信息后,会创建群聊并返回新群聊的详细信息,包括群聊的ID、群聊成员的列表和一般的群聊信息。 98 | - 客户端接收到这些信息后,将会更新本地数据库,包括群聊信息表和群聊成员信息表,保证用户界面能够及时反映新创建的群聊及其成员信息。 99 | - 被邀请的成员将通过WebSocket收到通知,包含群聊的详细信息及成员列表。收到邀请的用户同样需要在本地相应表中更新以上信息。 100 | 101 | #### 邀请新成员加入 102 | 103 | 在群聊创建后,群聊成员可能需要邀请更多的好友加入群聊。这一功能的实现逻辑如下: 104 | 105 | - 现有群聊成员可通过界面上的“邀请成员”选项,选择并邀请新的好友加入群聊。 106 | - 确认邀请后,客户端会将被邀请人的信息发送至服务端,并请求更新群聊成员列表。 107 | - 服务端接收并处理这一请求后,会更新群聊数据库中的相关信息,并通知所有群聊成员新成员的加入。 108 | - 新成员将通过WebSocket消息获得群聊邀请,此消息含有群聊的基本信息和现有成员列表。接受邀请后,新成员也会在本地数据库中更新群聊信息,完成加群流程。 109 | 110 | #### 退出群聊和解散群聊 111 | 112 | - **退出群聊**:用户可选择退出群聊,此时客户端会向服务端发送退出请求。服务端处理后将该成员从群聊成员列表中移除,并通过WebSocket通知其他群成员。 113 | - **解散群聊**:群主有权限解散群聊。执行解散操作时,服务端将删除该群聊的所有记录,并通过WebSocket通知所有成员群聊已被解散,客户端在接收到通知后,将相应群聊信息从本地数据库中删除。 114 | 115 | 116 | 117 | ### 音视频通话 118 | 119 | 音视频通话基于浏览器原生api--webRTC实现,算是项目中的一个难点,下面进行详细介绍: 120 | 121 | 时序图 122 | 123 | ![sequence](./images/phone_call.png) 124 | 125 | WebRTC技术为我们的即时通讯提供了音视频通话的可能性。其核心部分建立在我们的websocket服务之上,通过所谓的“信令通道”交换用户之间必要的点对点(P2P)连接信息,比如用于通话的媒体信息(编码格式)、网络信息(包括IP地址和端口号)、以及用于控制通话进行的一些消息。在开始交换这些P2P连接信息之前,我们还需要发送一个通话邀请给接收方。整个过程如下: 126 | 127 | 1. **发送通话邀请**:首先,用户A通过点击通话按钮向用户B发送一个通话邀请。 128 | 2. **收到通话邀请**:用户B收到通话邀请的通知后,可以选择接受或者拒绝。如果用户B接受了通话,系统就开始建立P2P连接。 129 | 3. **创建“RTCPeerConnection”对象**:系统会在每一位参与通话的用户的浏览器创建一个RTCPeerConnection对象。这个对象负责管理WebRTC连接的整个生命周期。 130 | 4. **交换网络信息(ICE Candidates)**:这是为了让通话双方了解对方的网络环境,包括双方的IP地址和端口号等。 131 | 5. **交换会话描述(SDP)**:这一步是为了让双方都了解通话中将要使用的媒体参数,包括媒体的类型、编解码信息等。 132 | 6. **NAT穿越和中继(STUN/TURN)**:为了解决用户可能位于NAT 或防火墙后的情况,WebRTC使用了STUN 和TURN 服务器来发现设备的公网IP地址和端口,如果必要的话,可能需要使用中继方式。 133 | 7. **建立连接**:当双方交换了ICE候选和会话描述(SDP)后,系统开始尝试建立连接,首先会试图让通话双方直接连接,如果失败,再使用TURN服务器进行中继。 134 | 8. **媒体流传输**:一旦连接建立,就可以开始传输媒体流,比如视频和音频了。 135 | 9. **通信和维护**:在通信过程中,我们还会维护对方的状态,适时更新ICE候选,确保通话的稳定性。 136 | 10. **关闭连接**: 通话结束后,我们需要关闭RTCPeerConnection,结束会话。 137 | 138 | 整个过程,除了建立P2P连接这部分WebRTC会自动完成外,其余部分如通话邀请、通话邀请取消、通话超时、通话挂断等需要我们自行设计和实现。总的来说,WebRTC流程繁多但层层递进,最终实现了安全、高效的音视频通话功能。 139 | 140 | **总结:**以上是对整个项目的整体概述以及一些功能的实现介绍,整个项目还在快速开发迭代中,感兴趣的话可以持续跟进,后续会对后端架构以及实现细节进行详细表述 141 | 142 | 项目源码: 143 | 144 | - 前端[[Xu-Mj/sandcat: im app frontend (github.com)](https://github.com/Xu-Mj/sandcat) 145 | 146 | - 后端[[Xu-Mj/sandcat-backend: im app backend (github.com)](https://github.com/Xu-Mj/sandcat-backend) 147 | -------------------------------------------------------------------------------- /rfcs/images/contacts.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/contacts.jpg -------------------------------------------------------------------------------- /rfcs/images/framework-english.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/framework-english.png -------------------------------------------------------------------------------- /rfcs/images/home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/home.jpg -------------------------------------------------------------------------------- /rfcs/images/image-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/image-login.png -------------------------------------------------------------------------------- /rfcs/images/phone_call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/phone_call.png -------------------------------------------------------------------------------- /rfcs/images/register.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/register.jpg -------------------------------------------------------------------------------- /rfcs/images/seq-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/seq-chart.png -------------------------------------------------------------------------------- /rfcs/images/时序图.awebp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/时序图.awebp -------------------------------------------------------------------------------- /rfcs/images/架构图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xu-Mj/sandcat-backend/930df4e6652eb0624ee82b6a6590ebf7f7a99701/rfcs/images/架构图.png -------------------------------------------------------------------------------- /rfcs/readme_cn.md: -------------------------------------------------------------------------------- 1 | ## IM 后端架构说明 2 | 3 | ### 项目简介 4 | 5 | 本项目是一个即时通讯(IM)系统后端实现,采用Rust编程语言开发,以微服务架构设计,旨在为开发者提供一个高效、稳定和可扩展的IM解决方案。项目的重点在于探索并展示Rust在构建高性能后端服务时的潜力和实践。 6 | 7 | ### 主要特点 8 | 9 | - **微服务架构:** 系统被拆分成多个独立的服务单元,每个服务单元负责核心业务逻辑的一部分,以及与其他服务的通信。 10 | 11 | - **容器化部署:** 所有服务均可以通过Docker进行打包,易于部署和管理。 12 | 13 | - **异步处理:** 使用Rust的异步编程特性来处理并发工作负载,增强性能和吞吐量。 14 | 15 | - **数据存储:** 使用PostgreSQL和MongoDB数据库,分别负责消息永久存储和收件箱功能。 16 | 17 | - **消息队列:** 使用Kafka作为消息队列,以支持高并发消息推送与处理。 18 | 19 | ### 架构组件 20 | 21 | 1. **服务层** 22 | - **认证服务:** 处理用户的注册、登录和验证。 23 | - **消息服务:** 负责消息的发送、接收和转发。 24 | - **好友服务:** 管理用户的好友列表和状态。 25 | - **群组服务:** 管理群组的创建、消息群发和成员管理。 26 | 27 | 2. **数据存储层** 28 | - **PostgreSQL:** 负责存储用户信息、好友关系和消息历史,结合定时任务实现数据的自动归档。 29 | - **MongoDB:** 用作消息的收件箱,处理离线消息的存储和查询。 30 | 31 | 3. **中间件层** 32 | - **Kafka:** 提供高吞吐量的消息队列,使服务之间解耦。 33 | - **Redis:** 实现缓存和消息状态的维护,优化数据库加载。 34 | 35 | 4. **基础设施层** 36 | - **Docker和Docker-Compose:** 用于构建和部署服务的容器。 37 | - **Consul:** 服务的注册与发现。 38 | - **MinIO:** 对象存储解决方案,用于文件上传和下载。 39 | 40 | ### 性能与伸缩性 41 | 42 | 本项目设计时考虑了高性能和水平伸缩性。通过异步处理和微服务架构,系统能够在负载增加时通过增加服务实例数量来实现有效的扩展。同时,项目采用了modular的设计理念,允许开发者根据需要定制或替换模块。 43 | 44 | ### 贡献和社区 45 | 46 | 项目开源,鼓励Rust爱好者和即时通讯系统开发者参与贡献。我们相信社区的力量可以推动项目向更完善和成熟的方向发展。如果您有任何建议或想要贡献代码,请查看我们的GitHub仓库并参与其中。 47 | -------------------------------------------------------------------------------- /rfcs/template.md: -------------------------------------------------------------------------------- 1 | # Feature 2 | 3 | - Feature Name: im-backend 4 | - Start Date: 2024-03-21 5 | 6 | ## Summary 7 | 8 | A im application backend. 9 | 10 | ## Motivation 11 | 12 | first purpose: study rust. 13 | 14 | ## Guide-level explanation 15 | 16 | ## Reference-level explanation 17 | 18 | - abi 19 | all foundations include: domain models, configuration, etc. 20 | - chat 21 | business logic 22 | - ws 23 | message gateway, to handle the message of client sent and send message to client by websocket. 24 | - rpc 25 | handle the rpc request from other services. 26 | - api 27 | offer api for other services to call. based on rpc. 28 | - db 29 | database layer, consumer the mq data to postgres, mongodb and reds. 30 | ![core flow](images/seq-chart.png) 31 | 32 | ## Unresolved questions 33 | 34 | - save the message sequence to redis: we don't know if the seq is correct, we just increase it now. 35 | - how to handle the message sequence when the message is sent to the database module failed. 36 | - tonic grpc client load balance: it's just basic load balance for now, and it doesn't implement the get new service 37 | list in the interval time. 38 | - need to design a websocket register center, to achieve the load balance. 39 | - shall we put the method that we get members id from cache into db service? 40 | - friendship need to redesign 41 | - conversation has nothing yet, it's only on the client side. 42 | - partition table for message have not been implemented yet. 43 | - GROUP MESSAGE SEQUENCE: WE INCREASE THE SEQUENCE AT CONSUMER MODULE, AND NEED TO GET SEQUENCE AT WS/MONGODB MODULE. IS 44 | THERE ANY EFFECTIVE WAY TO PERFORMANT? 45 | - timestamp issue: we use the time millis(i64) as the timestamp in database, but we should use the TimeStamp in the 46 | future. 47 | - axum's routes layer or with_state? 48 | - user table should add login device, used to check if the client need to sync the friend list 49 | 50 | ## Future possibilities 51 | -------------------------------------------------------------------------------- /utils/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "utils" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | abi = { version = "0.1.0", path = "../abi" } 10 | 11 | axum = "0.7.4" 12 | argon2 = "0.5.3" 13 | chrono = "0.4.35" 14 | hostname = "0.4" 15 | http = "0.2.12" 16 | mongodb = "2.8.2" 17 | reqwest = { version = "0.12.2", features = ["json"] } 18 | serde = "1.0.197" 19 | sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres"] } 20 | tokio = { version = "1.36.0", features = ["macros", "rt", "rt-multi-thread"] } 21 | tonic = "0.11.0" 22 | tower = "0.4.13" 23 | tracing = "0.1.40" 24 | uuid = { version = "1.7.0", features = ["v4"] } 25 | async-trait = "0.1.80" 26 | synapse = { git = "https://github.com/Xu-Mj/synapse.git", branch = "main" } 27 | -------------------------------------------------------------------------------- /utils/migrations/20240301073623_todo.down.sql: -------------------------------------------------------------------------------- 1 | -- Add down migration script here 2 | -------------------------------------------------------------------------------- /utils/migrations/20240301073623_todo.up.sql: -------------------------------------------------------------------------------- 1 | -- create tables todos 2 | CREATE TABLE todos ( 3 | id SERIAL PRIMARY KEY, 4 | title VARCHAR(255) NOT NULL, 5 | description VARCHAR(255), 6 | completed BOOLEAN NOT NULL DEFAULT false, 7 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 8 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 9 | ); 10 | -------------------------------------------------------------------------------- /utils/src/client_factory.rs: -------------------------------------------------------------------------------- 1 | use abi::message::{chat_service_client::ChatServiceClient, msg_service_client::MsgServiceClient}; 2 | 3 | use crate::service_discovery::LbWithServiceDiscovery; 4 | 5 | pub trait ClientFactory { 6 | // 可能的共有方法,正示例 7 | fn n(channel: LbWithServiceDiscovery) -> Self; 8 | } 9 | 10 | impl ClientFactory for ChatServiceClient { 11 | fn n(channel: LbWithServiceDiscovery) -> Self { 12 | // 实现细节... 13 | Self::new(channel) 14 | } 15 | } 16 | 17 | impl ClientFactory for MsgServiceClient { 18 | fn n(channel: LbWithServiceDiscovery) -> Self { 19 | // 实现细节... 20 | Self::new(channel) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /utils/src/mongodb_tester/mod.rs: -------------------------------------------------------------------------------- 1 | use std::thread; 2 | 3 | use mongodb::Database; 4 | use tokio::runtime::Runtime; 5 | 6 | pub struct MongoDbTester { 7 | pub host: String, 8 | pub port: u16, 9 | pub user: String, 10 | pub password: String, 11 | pub dbname: String, 12 | } 13 | 14 | impl MongoDbTester { 15 | pub async fn new( 16 | host: impl Into, 17 | port: u16, 18 | user: impl Into, 19 | password: impl Into, 20 | ) -> MongoDbTester { 21 | let uuid = uuid::Uuid::new_v4(); 22 | let dbname = format!("test_{}", uuid); 23 | let tdb = MongoDbTester { 24 | host: host.into(), 25 | port, 26 | user: user.into(), 27 | password: password.into(), 28 | dbname: dbname.clone(), 29 | }; 30 | let server_url = tdb.server_url(); 31 | let client = mongodb::Client::with_uri_str(server_url).await.unwrap(); 32 | client.database(&dbname); 33 | tdb 34 | } 35 | pub fn server_url(&self) -> String { 36 | match (self.user.is_empty(), self.password.is_empty()) { 37 | (true, _) => { 38 | format!("mongodb://{}:{}", self.host, self.port) 39 | } 40 | (false, true) => { 41 | format!("mongodb://{}@{}:{}", self.user, self.host, self.port) 42 | } 43 | (false, false) => { 44 | format!( 45 | "mongodb://{}:{}@{}:{}", 46 | self.user, self.password, self.host, self.port 47 | ) 48 | } 49 | } 50 | } 51 | 52 | pub fn url(&self) -> String { 53 | format!("{}/{}", self.server_url(), self.dbname) 54 | } 55 | 56 | pub fn dbname(&self) -> String { 57 | self.dbname.clone() 58 | } 59 | 60 | pub async fn database(&self) -> Database { 61 | mongodb::Client::with_uri_str(self.url()) 62 | .await 63 | .unwrap() 64 | .database(&self.dbname) 65 | } 66 | } 67 | 68 | impl Drop for MongoDbTester { 69 | fn drop(&mut self) { 70 | let server_url = self.server_url(); 71 | let dbname = self.dbname.clone(); 72 | // drop database 73 | thread::spawn(move || { 74 | Runtime::new().unwrap().block_on(async move { 75 | let client = mongodb::Client::with_uri_str(server_url).await.unwrap(); 76 | if let Err(e) = client.database(&dbname).drop(None).await { 77 | println!("drop database error: {}", e); 78 | } 79 | println!("drop trait over{}", dbname); 80 | }); 81 | }) 82 | .join() 83 | .unwrap(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /utils/src/service_discovery/mod.rs: -------------------------------------------------------------------------------- 1 | mod service_fetcher; 2 | mod tonic_service_discovery; 3 | 4 | pub use service_fetcher::*; 5 | pub use tonic_service_discovery::*; 6 | -------------------------------------------------------------------------------- /utils/src/service_discovery/service_fetcher.rs: -------------------------------------------------------------------------------- 1 | use abi::errors::Error; 2 | use async_trait::async_trait; 3 | use std::collections::HashSet; 4 | use std::net::SocketAddr; 5 | 6 | #[async_trait] 7 | pub trait ServiceFetcher: Send + Sync { 8 | async fn fetch(&self) -> Result, Error>; 9 | } 10 | -------------------------------------------------------------------------------- /utils/src/service_discovery/tonic_service_discovery.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::net::SocketAddr; 3 | use std::task::{Context, Poll}; 4 | 5 | use tokio::sync::mpsc; 6 | use tonic::body::BoxBody; 7 | use tonic::client::GrpcService; 8 | use tonic::transport::{Channel, Endpoint}; 9 | use tower::discover::Change; 10 | use tracing::{error, warn}; 11 | 12 | use abi::errors::Error; 13 | 14 | use crate::service_discovery::service_fetcher::ServiceFetcher; 15 | 16 | /// custom load balancer for tonic 17 | #[derive(Debug, Clone)] 18 | pub struct LbWithServiceDiscovery(pub Channel); 19 | 20 | /// implement the tonic service for custom load balancer 21 | impl tower::Service> for LbWithServiceDiscovery { 22 | type Response = http::Response<>::ResponseBody>; 23 | type Error = >::Error; 24 | type Future = >::Future; 25 | 26 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { 27 | GrpcService::poll_ready(&mut self.0, cx) 28 | } 29 | 30 | fn call(&mut self, request: http::Request) -> Self::Future { 31 | GrpcService::call(&mut self.0, request) 32 | } 33 | } 34 | 35 | pub struct DynamicServiceDiscovery { 36 | services: HashSet, 37 | sender: mpsc::Sender>, 38 | dis_interval: tokio::time::Duration, 39 | service_center: Fetcher, 40 | schema: String, 41 | } 42 | 43 | impl DynamicServiceDiscovery { 44 | pub fn new( 45 | service_center: Fetcher, 46 | dis_interval: tokio::time::Duration, 47 | sender: mpsc::Sender>, 48 | schema: String, 49 | ) -> Self { 50 | Self { 51 | services: Default::default(), 52 | sender, 53 | dis_interval, 54 | service_center, 55 | schema, 56 | } 57 | } 58 | 59 | /// execute discovery once 60 | pub async fn discovery(&mut self) -> Result<(), Error> { 61 | //get services from service register center 62 | let x = self.service_center.fetch().await?; 63 | let change_set = self.change_set(&x).await; 64 | for change in change_set { 65 | self.sender.send(change).await.map_err(Error::internal)?; 66 | } 67 | self.services = x; 68 | Ok(()) 69 | } 70 | 71 | async fn change_set( 72 | &self, 73 | endpoints: &HashSet, 74 | ) -> Vec> { 75 | let mut changes = Vec::new(); 76 | for s in endpoints.difference(&self.services) { 77 | if let Some(endpoint) = self.build_endpoint(*s).await { 78 | changes.push(Change::Insert(*s, endpoint)); 79 | } 80 | } 81 | for s in self.services.difference(endpoints) { 82 | changes.push(Change::Remove(*s)); 83 | } 84 | changes 85 | } 86 | 87 | async fn build_endpoint(&self, address: SocketAddr) -> Option { 88 | let url = format!("{}://{}:{}", self.schema, address.ip(), address.port()); 89 | let endpoint = Endpoint::from_shared(url) 90 | .map_err(|e| warn!("build endpoint error:{:?}", e)) 91 | .ok()?; 92 | Some(endpoint) 93 | } 94 | 95 | pub async fn run(mut self) { 96 | loop { 97 | tokio::time::sleep(self.dis_interval).await; 98 | // get services from service register center 99 | if let Err(e) = self.discovery().await { 100 | error!("discovery error:{:?}", e); 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /utils/src/service_register_center/consul/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use async_trait::async_trait; 4 | use tracing::debug; 5 | 6 | use abi::config::Config; 7 | use abi::errors::Error; 8 | 9 | use crate::service_register_center::typos::Registration; 10 | use crate::service_register_center::{ServiceRegister, Services}; 11 | 12 | /// consul options 13 | #[derive(Debug, Clone)] 14 | pub struct ConsulOptions { 15 | pub host: String, 16 | pub port: u16, 17 | pub protocol: String, 18 | pub timeout: u64, 19 | } 20 | 21 | impl ConsulOptions { 22 | #[allow(dead_code)] 23 | pub fn from_config(config: &Config) -> Self { 24 | Self { 25 | host: config.service_center.host.clone(), 26 | port: config.service_center.port, 27 | timeout: config.service_center.timeout, 28 | protocol: config.service_center.protocol.clone(), 29 | } 30 | } 31 | } 32 | 33 | #[derive(Debug, Clone)] 34 | pub struct Consul { 35 | pub options: ConsulOptions, 36 | pub client: reqwest::Client, 37 | } 38 | 39 | impl Consul { 40 | pub fn from_config(config: &Config) -> Self { 41 | let options = ConsulOptions::from_config(config); 42 | let client = reqwest::Client::builder() 43 | .timeout(std::time::Duration::from_millis(options.timeout)) 44 | .no_proxy() 45 | .build() 46 | .unwrap(); 47 | Self { options, client } 48 | } 49 | 50 | pub fn api_url(&self, name: &str) -> String { 51 | self.url("agent", name) 52 | } 53 | 54 | fn url(&self, type_: &str, name: &str) -> String { 55 | format!( 56 | "{}://{}:{}/v1/{}/{}", 57 | self.options.protocol, self.options.host, self.options.port, type_, name 58 | ) 59 | } 60 | } 61 | 62 | #[async_trait] 63 | impl ServiceRegister for Consul { 64 | async fn register(&self, registration: Registration) -> Result<(), Error> { 65 | let url = self.api_url("service/register"); 66 | let response = self.client.put(&url).json(®istration).send().await?; 67 | debug!("register service: {:?} to consul{url}", registration); 68 | if !response.status().is_success() { 69 | return Err(Error::internal_with_details( 70 | response.text().await.unwrap_or_default(), 71 | )); 72 | } 73 | Ok(()) 74 | } 75 | 76 | async fn discovery(&self) -> Result { 77 | let url = self.api_url("services"); 78 | let services = self 79 | .client 80 | .get(url) 81 | .send() 82 | .await? 83 | .json::() 84 | .await?; 85 | Ok(services) 86 | } 87 | 88 | async fn deregister(&self, service_id: &str) -> Result<(), Error> { 89 | let url = self.api_url(&format!("service/deregister/{}", service_id)); 90 | let response = self.client.put(url).send().await?; 91 | if !response.status().is_success() { 92 | return Err(Error::internal_with_details( 93 | response.text().await.unwrap_or_default(), 94 | )); 95 | } 96 | Ok(()) 97 | } 98 | 99 | async fn filter_by_name(&self, name: &str) -> Result { 100 | let url = self.api_url("services"); 101 | let mut map = HashMap::new(); 102 | map.insert("filter", format!("Service == {}", name)); 103 | 104 | let services = self 105 | .client 106 | .get(url) 107 | .query(&map) 108 | .send() 109 | .await? 110 | .json::() 111 | .await?; 112 | Ok(services) 113 | } 114 | } 115 | 116 | #[cfg(test)] 117 | mod tests { 118 | // use super::*; 119 | 120 | // #[tokio::test] 121 | // async fn register_deregister_should_work() { 122 | // let config = Config::load("../config.yml").unwrap(); 123 | // 124 | // let consul = Consul::from_config(&config); 125 | // let registration = Registration { 126 | // id: "test".to_string(), 127 | // name: "test".to_string(), 128 | // address: "127.0.0.1".to_string(), 129 | // port: 8081, 130 | // tags: vec!["test".to_string()], 131 | // check: None, 132 | // }; 133 | // let result = consul.register(registration).await; 134 | // assert!(result.is_ok()); 135 | // // delete it 136 | // let result = consul.deregister("test").await; 137 | // assert!(result.is_ok()); 138 | // } 139 | 140 | // #[tokio::test] 141 | // async fn discovery_should_work() { 142 | // let config = Config::load("../abi/fixtures/im.yml").unwrap(); 143 | // 144 | // let consul = Consul::from_config(&config); 145 | // let registration = Registration { 146 | // id: "test".to_string(), 147 | // name: "test".to_string(), 148 | // address: "127.0.0.1".to_string(), 149 | // port: 8080, 150 | // tags: vec!["test".to_string()], 151 | // check: None, 152 | // }; 153 | // let result = consul.register(registration).await; 154 | // assert!(result.is_ok()); 155 | // // get it 156 | // let result = consul.discovery().await; 157 | // assert!(result.is_ok()); 158 | // assert!(result.unwrap().contains_key("test")); 159 | // 160 | // let registration = Registration { 161 | // id: "example".to_string(), 162 | // name: "example".to_string(), 163 | // address: "127.0.0.1".to_string(), 164 | // port: 8081, 165 | // tags: vec!["example".to_string()], 166 | // check: None, 167 | // }; 168 | // let result = consul.register(registration).await; 169 | // assert!(result.is_ok()); 170 | // let result = consul.filter_by_name("example").await; 171 | // println!("{:?}", result); 172 | // assert!(result.is_ok()); 173 | // let result = result.unwrap(); 174 | // assert_eq!(result.len(), 1); 175 | // assert!(result.contains_key("example")); 176 | // // delete it 177 | // // let result = consul.deregister("test").await; 178 | // // assert!(result.is_ok()); 179 | // } 180 | } 181 | -------------------------------------------------------------------------------- /utils/src/service_register_center/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::service_register_center::typos::{Registration, Service}; 2 | use abi::config::Config; 3 | use abi::errors::Error; 4 | use async_trait::async_trait; 5 | use std::collections::HashMap; 6 | use std::fmt::Debug; 7 | use std::sync::Arc; 8 | 9 | mod consul; 10 | pub mod typos; 11 | 12 | pub type Services = HashMap; 13 | /// the service register discovery center 14 | #[async_trait] 15 | pub trait ServiceRegister: Send + Sync + Debug { 16 | /// service register 17 | async fn register(&self, registration: Registration) -> Result<(), Error>; 18 | 19 | /// service discovery 20 | async fn discovery(&self) -> Result; 21 | 22 | /// service deregister 23 | async fn deregister(&self, service_id: &str) -> Result<(), Error>; 24 | 25 | /// filter 26 | async fn filter_by_name(&self, name: &str) -> Result; 27 | } 28 | 29 | pub fn service_register_center(config: &Config) -> Arc { 30 | Arc::new(consul::Consul::from_config(config)) 31 | } 32 | -------------------------------------------------------------------------------- /utils/src/service_register_center/typos.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Debug, Default)] 4 | pub struct Registration { 5 | pub id: String, 6 | pub name: String, 7 | pub address: String, 8 | pub port: u16, 9 | pub tags: Vec, 10 | pub check: Option, 11 | } 12 | 13 | #[derive(Serialize, Deserialize, Debug, Default)] 14 | pub struct GrpcHealthCheck { 15 | pub name: String, 16 | pub grpc: String, 17 | pub grpc_use_tls: bool, 18 | pub interval: String, 19 | } 20 | 21 | /// returned type from register center 22 | #[derive(Serialize, Deserialize, Debug, Default, Clone)] 23 | pub struct Service { 24 | #[serde(rename = "ID")] 25 | pub id: String, 26 | #[serde(rename = "Service")] 27 | pub service: String, 28 | #[serde(rename = "Address")] 29 | pub address: String, 30 | #[serde(rename = "Port")] 31 | pub port: u16, 32 | #[serde(rename = "Tags")] 33 | pub tags: Vec, 34 | #[serde(rename = "Datacenter")] 35 | pub datacenter: String, 36 | } 37 | -------------------------------------------------------------------------------- /utils/src/sqlx_tester/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, thread}; 2 | 3 | use sqlx::PgPool; 4 | use tokio::runtime::Runtime; 5 | 6 | /// create a struct which has ability to create database automatically and drop it automatically when it is dropped. 7 | pub struct TestDb { 8 | pub host: String, 9 | pub port: u16, 10 | pub user: String, 11 | pub password: String, 12 | pub dbname: String, 13 | } 14 | 15 | /// format url 16 | impl TestDb { 17 | pub fn new( 18 | host: impl Into, 19 | port: u16, 20 | user: impl Into, 21 | password: impl Into, 22 | migrations: impl Into, 23 | ) -> TestDb { 24 | let uuid = uuid::Uuid::new_v4(); 25 | let dbname = format!("test_{}", uuid); 26 | let tdb = TestDb { 27 | host: host.into(), 28 | port, 29 | user: user.into(), 30 | password: password.into(), 31 | dbname: dbname.clone(), 32 | }; 33 | let server_url = tdb.server_url(); 34 | let url = tdb.url(); 35 | let migrations = migrations.into(); 36 | // create database in tokio runtime 37 | thread::spawn(move || { 38 | Runtime::new().unwrap().block_on(async move { 39 | let conn = PgPool::connect(&server_url).await.unwrap(); 40 | 41 | // create database for test 42 | sqlx::query(&format!(r#"CREATE DATABASE "{}""#, dbname)) 43 | .execute(&conn) 44 | .await 45 | .unwrap(); 46 | 47 | // run migrations 48 | let conn = PgPool::connect(&url).await.unwrap(); 49 | sqlx::migrate::Migrator::new(Path::new(&migrations)) 50 | .await 51 | .unwrap() 52 | .run(&conn) 53 | .await 54 | .unwrap(); 55 | }); 56 | }) 57 | .join() 58 | .unwrap(); 59 | tdb 60 | } 61 | 62 | pub fn server_url(&self) -> String { 63 | if self.password.is_empty() { 64 | format!("postgres://{}@{}:{}", self.user, self.host, self.port) 65 | } else { 66 | format!( 67 | "postgres://{}:{}@{}:{}", 68 | self.user, self.password, self.host, self.port 69 | ) 70 | } 71 | } 72 | 73 | pub fn url(&self) -> String { 74 | format!("{}/{}", self.server_url(), self.dbname) 75 | } 76 | 77 | pub async fn pool(&self) -> PgPool { 78 | sqlx::Pool::connect(&self.url()).await.unwrap() 79 | } 80 | 81 | pub fn dbname(&self) -> String { 82 | self.dbname.clone() 83 | } 84 | } 85 | 86 | impl Drop for TestDb { 87 | fn drop(&mut self) { 88 | let server_url = self.server_url(); 89 | let dbname = self.dbname.clone(); 90 | thread::spawn(move || { 91 | Runtime::new().unwrap().block_on(async move { 92 | let conn = PgPool::connect(&server_url).await.unwrap(); 93 | // close other connections 94 | sqlx::query(&format!(r#"SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '{dbname}' AND pid <> pg_backend_pid();"#)) 95 | .execute(&conn) 96 | .await 97 | .unwrap(); 98 | sqlx::query(&format!(r#"DROP DATABASE "{dbname}""#)) 99 | .execute(&conn) 100 | .await 101 | .unwrap(); 102 | }); 103 | }).join().unwrap(); 104 | } 105 | } 106 | 107 | #[cfg(test)] 108 | mod tests { 109 | use super::TestDb; 110 | 111 | #[tokio::test] 112 | async fn it_works() { 113 | let tdb = TestDb::new("localhost", 5432, "postgres", "postgres", "./migrations"); 114 | sqlx::query("INSERT INTO todos (id, title) VALUES (1, 'test');") 115 | .execute(&tdb.pool().await) 116 | .await 117 | .unwrap(); 118 | } 119 | } 120 | --------------------------------------------------------------------------------