65.9K
CodeProject 正在变化。 阅读更多。
Home

Movin' In - 租赁房产管理平台,附带移动应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (21投票s)

2023年9月20日

MIT

41分钟阅读

viewsIcon

65623

downloadIcon

1561

租赁房产管理平台,附带移动应用程序

目录

  1. 引言
  2. 特点
  3. 实时演示
  4. 必备组件
  5. 快速概览
    1. 前端
    2. 移动应用
    3. 后端
  6. 安装(自托管)
  7. 安装(VPS)
  8. 安装(Docker)
    1. Docker 镜像
    2. SSL
  9. 设置 Stripe
  10. 更改货币
  11. 添加新语言
  12. 演示数据库
    1. Windows, Linux, macOS
    2. Docker
  13. 构建移动应用
  14. 从源码运行
  15. 运行移动应用
  16. 单元测试和覆盖率
  17. 日志
  18. Using the Code
    1. API
    2. 前端
    3. 移动应用
    4. 后端
  19. 关注点
  20. 历史

引言

Movin' In 是一个面向代理的租赁房产管理平台,包含一个用于管理房产、客户和预订的后端,以及一个用于租赁房产的前端和移动应用程序。

Movin' In 设计用于支持多个代理机构。代理机构可以从后端管理他们的房产和预订。Movin' In 也可以只支持一个代理机构,并用作房产租赁聚合器。

通过后端,管理员可以创建和管理代理机构、房产、地点、用户和预订。

当创建新的代理机构时,它们会收到一封电子邮件,提示它们创建账户以访问后端并管理它们的房产、客户和预订。

客户可以通过前端或移动应用程序注册,根据地点和时间搜索可用房产,选择房产并完成结账流程。

一项重要的设计决策是使用 TypeScript 而非 JavaScript,因为其具有诸多优势。TypeScript 提供强类型、强大的工具和集成,从而实现高质量、可扩展、更易读、更易维护且易于调试和测试的代码。

Movin' In 可以运行在 Docker 容器中。在本文中,您可以找到如何构建 Movin' In Docker 镜像并在 Docker 容器中运行它。

在本文中,您将了解 Movin' In 的创建过程,包括源代码主要部分和软件架构的描述,如何部署它,以及如何运行源代码。但在深入研究之前,我们将从对平台的快速概览开始。

特点

  • 代理机构管理
  • 支持一个或多个代理机构
  • 房产管理
  • 预订管理
  • 支付管理
  • 客户管理
  • 多种支付方式(信用卡、PayPal、Google Pay、Apple Pay、Link、Pay Later)
  • 运营中的 Stripe 支付网关
  • 多语言支持(英语、法语)
  • 多种分页选项(经典分页,带上一页和下一页按钮;无限滚动)
  • 响应式后端和前端
  • 适用于 Android 和 iOS 的原生移动应用,单一代码库
  • 推送通知
  • 防范 XSS、XST、CSRF 和 MITM 攻击
  • 支持平台:iOS、Android、Web、Docker

实时演示

前端

后端

移动应用

您可以在任何 Android 设备上安装 Android 应用。

扫描此代码

使用设备扫描此代码

如何在 Android 上安装移动应用

  • 在运行 Android 8.0 (API 级别 26) 及更高版本的设备上,您必须导航到“安装未知应用”系统设置屏幕,以允许从特定位置(即您下载应用的浏览器)安装应用。

  • 在运行 Android 7.1.1 (API 级别 25) 及更低版本的设备上,您应该在设备的“设置”>“安全”中启用“未知来源”系统设置。

备用方法

您还可以通过直接下载 APK 文件并在任何 Android 设备上安装来安装 Android 应用。

  • 下载 APK
  • 登录:jdoe@movinin.io
  • 密码:M00vinin

必备组件

  • TypeScript
  • Node.js
  • Express
  • MongoDB
  • React
  • MUI
  • React Native
  • Expo
  • JWT
  • MVC
  • Docker
  • NGINX
  • Git

快速概览

在本节中,您将看到前端、后端和移动应用程序主要页面的快速概览。

前端

通过前端,客户可以搜索可用房产,选择房产并结账。

下方是前端主页,客户可以在其中选择地点和时间,并搜索可用房产。

下方是主页的搜索结果,客户可以在其中选择要租赁的房产。

下方是客户可以查看房产详情的页面

下方是房产图片视图

下方是结账页面,客户可以在其中设置租赁选项并结账。如果客户未注册,他可以同时结账和注册。如果客户尚未注册,他将收到一封确认和激活电子邮件,提示他设置密码。

下方是登录页面。在生产环境中,身份验证 cookie 是 httpOnly、签名、安全且具有严格的 sameSite。这些选项可防止 XSS、CSRF 和 MITM 攻击。身份验证 cookie 也通过自定义中间件来防止 XST 攻击。

下方是注册页面。

下方是客户可以查看和管理其预订的页面。

下方是客户可以查看预订详情的页面。

下方是客户可以查看和管理其通知的页面。

下方是客户可以管理其设置的页面。

下方是客户可以更改其密码的页面。

还有其他页面,但这些是前端的主要页面。

移动应用

通过移动应用程序,客户可以搜索可用房产,选择房产并结账。

如果客户的预订状态从后端更新,客户将收到推送通知。

以下是移动应用程序的主要页面,客户可以在其中选择地点和时间,并搜索可用房产。

以下是主页的搜索结果,客户可以在其中选择要租赁的房产并结账。

以下是登录和注册页面。

以下是客户可以查看和管理其预订的页面。

以下是客户可以更新其个人信息、更改密码和管理其通知的页面。

以上是移动应用程序主要页面的内容。

后端

Movin' In 面向代理机构。这意味着有三种用户类型:

  • 管理员:他们可以完全访问后端。他们可以做任何事情。
  • 代理机构:他们对后端有有限的访问权限。他们只能管理自己的房产、预订和客户。
  • 客户:他们只能访问前端和移动应用程序。他们无法访问后端。

Movin' In 设计用于支持多个代理机构。代理机构可以从后端管理它们的房产、客户和预订。Movin' In 也可以只支持一个代理机构。

通过后端,管理员可以创建和管理代理机构、房产、地点、客户和预订。

当创建新的代理机构时,它们会收到一封电子邮件,提示它们创建一个账户,以便访问后端并管理它们的房产和预订。

下方是后端的登录页面。

下方是后端的仪表板页面,管理员和代理机构可以在其中查看和管理预订。

如果预订状态发生变化,相关客户将收到推送通知和电子邮件。

下方是显示房产并可进行管理的页面。

下方是管理员和代理机构可以通过提供图片和房产信息来创建新房产的页面。要免费包含取消功能,请将其设置为 0。否则,设置选项的价格或留空如果您不想包含它。

下方是管理员和代理机构可以编辑房产的页面。

下方是管理员可以管理客户的页面。

下方是代理机构和管理员可以创建预订(如果他们想)的页面。否则,当从前端或移动应用程序完成结账过程时,预订会自动创建。

下方是编辑预订的页面。

下方是管理代理机构的页面。

下方是创建新代理机构的页面。

下方是编辑代理机构的页面。

下方是查看代理机构房产的页面。

下方是查看客户预订的页面。

下方是管理员和代理机构可以管理其设置的页面。

还有其他页面,但这些是后端的主要页面。

安装(自托管)

Movin' In 是跨平台的,可以在 Windows、Linux 和 macOS 上运行和安装。

下方是 Linux 上的安装说明。

必备组件

  1. 安装 gitNode.jsNGINXMongoDBmongosh。如果您想使用 MongoDB Atlas,可以跳过安装和配置 MongoDB。

  2. 配置 MongoDB

    mongosh

    创建管理员用户

    db = db.getSiblingDB('admin')
    db.createUser({ user: "admin" , pwd: "PASSWORD", 
    roles: ["userAdminAnyDatabase", "dbAdminAnyDatabase", "readWriteAnyDatabase"]})

    PASSWORD 替换为强密码。

    安全 MongoDB

    sudo nano /etc/mongod.conf

    按如下方式更改配置

    net:
      port: 27017
      bindIp: 0.0.0.0
    
    security:
      authorization: enabled

    重启 MongoDB 服务

    sudo systemctl restart mongod.service
    sudo systemctl status mongod.service

说明

  1. 克隆 movinin 仓库
    cd /opt
    sudo git clone https://github.com/aelassas/movinin.git
  2. 添加权限
    sudo chown -R $USER:$USER /opt/movinin
    sudo chmod -R +x /opt/movinin/__scripts
  3. 创建部署快捷方式
    sudo ln -s /opt/movinin/__scripts/mi-deploy.sh /usr/local/bin/mi-deploy
  4. 创建 movinin 服务
    sudo cp /opt/movinin/__services/movinin.service /etc/systemd/system
    sudo systemctl enable movinin.service
    
  5. 创建 /opt/movinin/api/.env 文件
    NODE_ENV=production
    MI_PORT=4004
    MI_HTTPS=false
    MI_PRIVATE_KEY=/etc/ssl/movinin.key
    MI_CERTIFICATE=/etc/ssl/movinin.pem
    MI_DB_URI=mongodb://admin:PASSWORD@127.0.0.1:27017/movinin?authSource=admin&appName=movinin
    MI_DB_SSL=false
    MI_DB_SSL_CERT=/etc/ssl/movinin.pem
    MI_DB_SSL_CA=/etc/ssl/movinin.pem
    MI_DB_DEBUG=false
    MI_COOKIE_SECRET=COOKIE_SECRET
    MI_AUTH_COOKIE_DOMAIN=localhost
    MI_JWT_SECRET=JWT_SECRET
    MI_JWT_EXPIRE_AT=86400
    MI_TOKEN_EXPIRE_AT=86400
    MI_SMTP_HOST=smtp.sendgrid.net
    MI_SMTP_PORT=587
    MI_SMTP_USER=apikey
    MI_SMTP_PASS=PASSWORD
    MI_SMTP_FROM=no-reply@movinin.io
    MI_CDN_USERS=/var/www/cdn/movinin/users
    MI_CDN_TEMP_USERS=/var/www/cdn/movinin/temp/users
    MI_CDN_PROPERTIES=/var/www/cdn/movinin/properties
    MI_CDN_TEMP_PROPERTIES=/var/www/cdn/temp/movinin/properties
    MI_CDN_LOCATIONS=/var/www/cdn/movinin/locations
    MI_CDN_TEMP_LOCATIONS=/var/www/cdn/movinin/temp/locations
    MI_DEFAULT_LANGUAGE=en
    MI_BACKEND_HOST=https://:3003/
    MI_FRONTEND_HOST=https:///
    MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN
    MI_MINIMUM_AGE=21
    MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
    MI_ADMIN_EMAIL=admin@movinin.io
    MI_RECAPTCHA_SECRET=RECAPTCHA_SECRET
    

    您需要配置以下选项

    MI_DB_URI=mongodb://admin:PASSWORD@127.0.0.1:27017/movinin?authSource=admin&appName=movinin
    MI_COOKIE_SECRET=COOKIE_SECRET
    MI_AUTH_COOKIE_DOMAIN=localhost
    MI_JWT_SECRET=JWT_SECRET
    MI_SMTP_HOST=smtp.sendgrid.net
    MI_SMTP_PORT=587
    MI_SMTP_USER=apikey
    MI_SMTP_PASS=PASSWORD
    MI_SMTP_FROM=no-reply@movinin.io
    MI_BACKEND_HOST=https://:3004/
    MI_FRONTEND_HOST=https:///

    如果您想使用 MongoDB Atlas,请将您的 MongoDB Atlas URI 放在 MI_DB_URI 中,否则请将 MI_DB_URI 中的 PASSWORD 替换为您的 MongoDB 密码。将 JWT_SECRET 替换为密钥令牌。最后,设置 SMTP 选项。SMTP 选项对于注册是必需的。您可以使用 sendgrid 或任何其他事务性电子邮件提供商。

    如果您选择 sendgrid,请在 sendgrid.com 上创建一个账户,登录并转到仪表板。在左侧面板中,单击“Email API”,然后单击“Integration Guide”。然后,选择“SMTP Relay”并按照步骤操作。系统会提示您创建一个 API 密钥。一旦您创建了 API 密钥并验证了 smtp 中继,请将 API 密钥复制到 ./api/.env 中的 MI_SMTP_PASS。Sendgrid 的免费套餐允许每天发送最多 100 封电子邮件。如果您需要每天发送超过 100 封电子邮件,请升级到付费套餐或选择其他事务性电子邮件提供商。

    COOKIE_SECRETJWT_SECRET 至少应为 32 个字符长,但越长越好。您可以使用在线密码生成器并将密码长度设置为 32 个或更长。

    以下设置非常重要,如果设置不正确,身份验证将无法工作

    MI_AUTH_COOKIE_DOMAIN=localhost
    MI_BACKEND_HOST=https://:3001/
    MI_FRONTEND_HOST=https:///
    

    localhost 替换为 IP 或 FQDN。也就是说,如果您从 http://<FQDN>:3001/ 访问后端。MI_BACKEND_HOST 应为 http://<FQDN>:3001/。MI_FRONTEND_HOST 也是如此。而 MI_AUTH_COOKIE_DOMAIN 应为 FQDN。

    如果您想在移动应用程序中启用推送通知,请遵循这些 说明 并设置以下选项

    MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN

    如果您想启用 Stripe 支付网关,请注册一个 stripe 账户,填写表格并保存 Stripe 开发者仪表板中的发布密钥和密钥。然后,在 api/.env 中的以下选项中设置密钥

    MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
    

    不要在网站上公开 Stripe 密钥或将其嵌入移动应用程序中。它必须保密并安全地存储在服务器端。

    在 Stripe 中,所有账户默认共有四个 API 密钥 - 两个用于测试模式,两个用于实时模式

    • 测试模式密钥:默认情况下,您可以使用此密钥在测试模式下对服务器上的请求进行身份验证。默认情况下,您可以使用此密钥不受限制地执行任何 API 请求。
    • 测试模式发布密钥:在 Web 或移动应用程序的客户端代码中,可以使用此密钥进行测试。
    • 实时模式密钥:在实时模式下,您可以使用此密钥在服务器上对请求进行身份验证。默认情况下,您可以使用此密钥不受限制地执行任何 API 请求。
    • 实时模式发布密钥:当您准备好启动应用程序时,可以使用此密钥在 Web 或移动应用程序的客户端代码中。

    仅使用您的测试 API 密钥进行测试。这样可以确保您不会意外修改您的实时客户或交易。

    如果您想启用 HTTPS,则需要设置以下选项

    MI_HTTPS = true
    MI_PRIVATE_KEY=/etc/ssl/movinin.key
    MI_CERTIFICATE=/etc/ssl/movinin.pem
  6. 创建 /opt/movinin/backend/.env 文件
    VITE_NODE_ENV=production
    VITE_MI_API_HOST=https://:4004
    VITE_MI_DEFAULT_LANGUAGE=en
    VITE_MI_PAGE_SIZE=30
    VITE_MI_PROPERTIES_PAGE_SIZE=15
    VITE_MI_BOOKINGS_PAGE_SIZE=20
    VITE_MI_BOOKINGS_MOBILE_PAGE_SIZE=10
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_TEMP_USERS=https:///cdn/movinin/temp/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_TEMP_PROPERTIES=https:///cdn/movinin/temp/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_CDN_TEMP_LOCATIONS=https:///cdn/movinin/temp/locations
    VITE_MI_AGENCY_IMAGE_WIDTH=60
    VITE_MI_AGENCY_IMAGE_HEIGHT=30
    VITE_MI_PROPERTY_IMAGE_WIDTH=300
    VITE_MI_PROPERTY_IMAGE_HEIGHT=200
    VITE_MI_MINIMUM_AGE=21
    VITE_MI_PAGINATION_MODE=classic

    您需要配置以下选项

    VITE_MI_API_HOST=https://:4004
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_TEMP_USERS=https:///cdn/movinin/temp/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_TEMP_PROPERTIES=https:///cdn/movinin/temp/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_CDN_TEMP_LOCATIONS=https:///cdn/movinin/temp/locations
    

    如果您想本地测试,请将 localhost 保留;否则,请将其替换为 IP、主机名或 FQDN。

    VITE_MI_PAGINATION_MODE:您可以在 classicinfinite_scroll 之间选择。此选项默认为 classic。如果您选择 classic,在桌面端您将获得经典的带上一页和下一页按钮的分页,在移动端获得无限滚动。如果您选择 infinite_scroll,您将在桌面端和移动端都获得无限滚动。

  7. 创建 /opt/movinin/frontend/.env 文件
    VITE_NODE_ENV=production
    VITE_MI_API_HOST=https://:4004
    VITE_MI_RECAPTCHA_ENABLED=false
    VITE_MI_RECAPTCHA_SITE_KEY=GOOGLE_RECAPTCHA_SITE_KEY
    VITE_MI_DEFAULT_LANGUAGE=en
    VITE_MI_PAGE_SIZE=30
    VITE_MI_PROPERTIES_PAGE_SIZE=15
    VITE_MI_BOOKINGS_PAGE_SIZE=20
    VITE_MI_BOOKINGS_MOBILE_PAGE_SIZE=10
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_AGENCY_IMAGE_WIDTH=60
    VITE_MI_AGENCY_IMAGE_HEIGHT=30
    VITE_MI_PROPERTY_IMAGE_WIDTH=300
    VITE_MI_PROPERTY_IMAGE_HEIGHT=200
    VITE_MI_MINIMUM_AGE=21
    VITE_MI_PAGINATION_MODE=classic
    VITE_MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    VITE_MI_STRIPE_CURRENCY_CODE=USD
    VITE_MI_CURRENCY=$
    VITE_MI_SET_LANGUAGE_FROM_IP=false
    VITE_MI_GOOGLE_ANALYTICS_ENABLED=false
    VITE_MI_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXXX
    VITE_MI_FB_APP_ID=XXXXXXXXXX
    VITE_MI_APPLE_ID=XXXXXXXXXX
    VITE_MI_GG_APP_ID=XXXXXXXXXX
    

    您需要配置以下选项

    VITE_MI_API_HOST=https://:4004
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    VITE_MI_STRIPE_CURRENCY_CODE=USD
    VITE_MI_CURRENCY=$
    VITE_MI_SET_LANGUAGE_FROM_IP=false
    VITE_MI_GOOGLE_ANALYTICS_ENABLED=false
    VITE_MI_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXXX
    VITE_MI_FB_APP_ID=XXXXXXXXXX
    VITE_MI_APPLE_ID=XXXXXXXXXX
    VITE_MI_GG_APP_ID=XXXXXXXXXX
    

    如果您想本地测试,请将 localhost 保留;否则,请将其替换为 IP、主机名或 FQDN。

    如果您想启用 Stripe 支付网关,请在 VITE_MI_STRIPE_PUBLISHABLE_KEY 中设置 Stripe 发布密钥。您可以从 Stripe 仪表板中检索它。

    VITE_MI_STRIPE_CURRENCY_CODE 是三位字母 ISO 4217 货币代码,例如“USD”或“EUR”。Stripe 支付必需。必须是受支持的货币:https://docs.stripe.com/currencies

    reCAPTCHA 默认禁用。如果您想启用它,需要将 VITE_MI_RECAPTCHA_ENABLED 设置为 true,并将 VITE_MI_RECAPTCHA_SITE_KEY 设置为 Google reCAPTCHA 站点密钥。

  8. 如果您想运行或构建移动应用程序,则需要创建 mobile/.env
    MI_API_HOST=https://movinin.io:4004
    MI_DEFAULT_LANGUAGE=en
    MI_PAGE_SIZE=20
    MI_PROPERTIES_PAGE_SIZE=8
    MI_BOOKINGS_PAGE_SIZE=8
    MI_CDN_USERS=https://movinin.io/cdn/movinin/users
    MI_CDN_PROPERTIES=https://movinin.io/cdn/movinin/properties
    MI_AGENCY_IMAGE_WIDTH=60
    MI_AGENCY_IMAGE_HEIGHT=30
    MI_PROPERTY_IMAGE_WIDTH=300
    MI_PROPERTY_IMAGE_HEIGHT=200
    MI_MINIMUM_AGE=21
    MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
    MI_STRIPE_COUNTRY_CODE=US
    MI_STRIPE_CURRENCY_CODE=USD
    

    您需要配置以下选项

    MI_API_HOST=https://movinin.io:4004
    MI_CDN_USERS=https://movinin.io/cdn/movinin/users
    MI_CDN_PROPERTIES=https://movinin.io/cdn/movinin/properties
    MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
    MI_STRIPE_COUNTRY_CODE=US
    MI_STRIPE_CURRENCY_CODE=USD
    

    https://movinin.io 替换为 IP、主机名或 FQDN。

    如果您想启用 Stripe 支付网关,请在 MI_STRIPE_PUBLISHABLE_KEY 中设置 Stripe 发布密钥。您可以从 Stripe 仪表板中检索它。

    MI_STRIPE_MERCHANT_IDENTIFIER 是您在 Apple 注册以与 Apple Pay 一起使用的商户标识符。

    MI_STRIPE_COUNTRY_CODE 是您企业的两字母 ISO 3166 国家代码,例如“US”。Stripe 支付必需。

    MI_STRIPE_CURRENCY_CODE 是三位字母 ISO 4217 货币代码,例如“USD”或“EUR”。Stripe 支付必需。必须是受支持的货币:https://docs.stripe.com/currencies

  9. 配置 NGINX
    sudo nano /etc/nginx/sites-available/default

    修改前端的配置如下

    server {
        root /var/www/movinin/frontend;
        #listen 443 http2 ssl default_server;
        listen 80 default_server;
        server_name _;
        
        #ssl_certificate_key /etc/ssl/movinin.key;
        #ssl_certificate /etc/ssl/movinin.pem;
    
        access_log /var/log/nginx/movinin.frontend.access.log;
        error_log /var/log/nginx/movinin.frontend.error.log;
    
        index index.html;
    
        location / {
          # First attempt to serve request as file, then as directory,
          # then as index.html, then fall back to displaying a 404.
          try_files $uri $uri/ /index.html =404;
        }
    
        location /cdn {
          alias /var/www/cdn;
        }
    }

    如果您想启用 SSL,请取消注释并设置这些行

    #listen 443 http2 ssl default_server
    #ssl_certificate_key /etc/ssl/movinin.com.key
    #ssl_certificate /etc/ssl/movinin.com.pem;

    为后端添加以下配置

    server {
        root /var/www/movinin/backend;
        #listen 3003 http2 ssl default_server;
        listen 3003 default_server;
        server_name _;
    
        #ssl_certificate_key /etc/ssl/movinin.key;
        #ssl_certificate /etc/ssl/movinin.pem;
    
        #error_page 497 301 =307 https://$host:$server_port$request_uri;
    
        access_log /var/log/nginx/movinin.backend.access.log;
        error_log /var/log/nginx/movinin.backend.error.log;
    
        index index.html;
    
        location / {
          # First attempt to serve request as file, then as directory,
          # then as index.html, then fall back to displaying a 404.
          try_files $uri $uri/ /index.html =404;
        }
    }

    创建 /var/www/cdn/movinin 文件夹,并为运行 movinin 服务的用户授予对 /var/www/cdn/movinin 的完全访问权限。

    如果您想启用 SSL,请取消注释并设置这些行

    #listen 3003 http2 ssl default_server
    #ssl_certificate_key /etc/ssl/movinin.com.key
    #ssl_certificate /etc/ssl/movinin.com.pem
    #error_page 497 301 =307 https://$host:$server_port$request_uri;

    然后,检查 NGINX 配置并重启 NGINX 服务

    sudo nginx -t
    sudo systemctl restart nginx.service
    sudo systemctl status nginx.service
  10. 启用防火墙并打开 movinin 端口
    sudo ufw enable
    sudo ufw allow 4004/tcp
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw allow 3003/tcp
    sudo ufw allow 27017/tcp
  11. 启动 movinin 服务
    cd /opt/movinin/api
    npm install --omit=dev
    sudo systemctl start movinin.service
    

    确保 movinin 服务正在运行,使用以下命令

    sudo systemctl status movinin.service
    

    通过检查日志来确保数据库连接已建立,使用以下命令

    tail -f /var/log/movinin.log

    或者这个命令

    sudo journalctl -xfu movinin.service

    或者通过打开此文件

    tail -f /opt/movinin/api/logs/all.log
    

    错误日志写入在

    tail -f /opt/movinin/api/logs/error.log
  12. 使用以下命令部署 movinin
    mi-deploy all

    Movin' In 后端可通过端口 3003 访问,前端可通过端口 80 访问。

    如果您想在内存量较少的 VPS 上安装 Movin' In,在运行 mi-deploy all 时可能会遇到内存问题。在这种情况下,您需要按以下步骤进行

    • 运行 mi-deploy api 来安装和运行 API。
    • 在您的桌面 PC 上,按照之前的说明设置 frontend/.env,然后从 frontend 文件夹运行以下命令
      npm install
      npm run build
    • frontend/build 的内容从您的桌面 PC 复制到 VPS 上的 /var/www/movinin/frontend
    • 在您的桌面 PC 上,按照之前的说明设置 backend/.env,然后从 backend 文件夹运行以下命令
      npm install
      npm run build
    • backend/build 的内容从您的桌面 PC 复制到 VPS 上的 /var/www/movinin/backend
    • 重启 NGINX
      sudo rm -rf /var/cache/nginx
      sudo systemctl restart nginx
      sudo systemctl status nginx
  13. 如果您不想使用演示数据库,请导航至 hostname:3003/sign-up 创建管理员。

  14. 打开 backend/src/App.tsx 并注释掉这些行以保护后端

    const SignUp = lazy(() => import('./pages/SignUp'))
    <Route exact path='/sign-up' element={<SignUp />} />

    然后再次运行后端部署

    mi-deploy backend

如果您只想部署前端,请运行以下命令

mi-deploy frontend

如果您只想部署 API,请运行以下命令

mi-deploy api

如果您想部署 API、后端和前端,请运行以下命令

mi-deploy all

要更改货币,请遵循这些 说明

安装(VPS)

指南 展示了如何在运行 Ubuntu 22.04 的 VPS 上安装 BookCars,该 VPS 至少拥有 512MB RAM 和一个 CPU。

安装(Docker)

Movin' In 可以在 Linux 上的 Docker 容器中运行,也可以在 Windows 或 Mac 的 Docker Desktop 中运行。

Docker 镜像

本节介绍了如何构建 Movin' In Docker 镜像并在 Docker 容器中运行它。

  1. 克隆 Movin' In 仓库
    git clone https://github.com/aelassas/movinin.git
  2. 创建 ./api/.env.docker 文件,内容如下
    NODE_ENV=production
    MI_PORT=4004
    MI_HTTPS=false
    MI_PRIVATE_KEY=/etc/ssl/movinin.key
    MI_CERTIFICATE=/etc/ssl/movinin.crt
    MI_DB_URI=mongodb://admin:PASSWORD@mongo:27017/movinin?authSource=admin&appName=movinin
    MI_DB_SSL=false
    MI_DB_SSL_CERT=/etc/ssl/movinin.crt
    MI_DB_SSL_CA=/etc/ssl/movinin.ca.pem
    MI_DB_DEBUG=false
    MI_COOKIE_SECRET=COOKIE_SECRET
    MI_AUTH_COOKIE_DOMAIN=localhost
    MI_JWT_SECRET=JWT_SECRET
    MI_JWT_EXPIRE_AT=86400
    MI_TOKEN_EXPIRE_AT=86400
    MI_SMTP_HOST=smtp.sendgrid.net
    MI_SMTP_PORT=587
    MI_SMTP_USER=apikey
    MI_SMTP_PASS=PASSWORD
    MI_SMTP_FROM=admin@movinin.io
    MI_CDN_USERS=/var/www/cdn/movinin/users
    MI_CDN_TEMP_USERS=/var/www/cdn/movinin/temp/users
    MI_CDN_PROPERTIES=/var/www/cdn/movinin/properties
    MI_CDN_TEMP_PROPERTIES=/var/www/cdn/movinin/temp/properties
    MI_CDN_LOCATIONS=/var/www/cdn/movinin/locations
    MI_CDN_TEMP_LOCATIONS=/var/www/cdn/movinin/temp/locations
    MI_DEFAULT_LANGUAGE=en
    MI_BACKEND_HOST=https://:3003/
    MI_FRONTEND_HOST=https:///
    MI_MINIMUM_AGE=21
    MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN
    MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
    MI_ADMIN_EMAIL=admin@movinin.io
    MI_RECAPTCHA_SECRET=RECAPTCHA_SECRET
    

    设置以下选项

    MI_DB_URI=mongodb://admin:PASSWORD@mongo:27017/movinin?authSource=admin&appName=movinin
    MI_COOKIE_SECRET=COOKIE_SECRET
    MI_AUTH_COOKIE_DOMAIN=localhost
    MI_JWT_SECRET=JWT_SECRET
    MI_SMTP_HOST=smtp.sendgrid.net
    MI_SMTP_PORT=587
    MI_SMTP_USER=apikey
    MI_SMTP_PASS=PASSWORD
    MI_SMTP_FROM=admin@movinin.io
    MI_BACKEND_HOST=https://:3003/
    MI_FRONTEND_HOST=https:/// 

    如果您想使用 MongoDB Atlas,请将您的 MongoDB Atlas URI 放在 MI_DB_URI 中,否则请将 MI_DB_URI 中的 PASSWORD 替换为您的 MongoDB 密码。将 JWT_SECRET 替换为密钥令牌。最后,设置 SMTP 选项。SMTP 选项对于注册是必需的。您可以使用 sendgrid 或任何其他事务性电子邮件提供商。

    如果您选择 sendgrid,请在 sendgrid.com 上创建一个账户,登录并转到仪表板。在左侧面板中,单击“Email API”,然后单击“Integration Guide”。然后,选择“SMTP Relay”并按照步骤操作。系统会提示您创建一个 API 密钥。一旦您创建了 API 密钥并验证了 smtp 中继,请将 API 密钥复制到 ./api/.env 中的 MI_SMTP_PASS。Sendgrid 的免费套餐允许每天发送最多 100 封电子邮件。如果您需要每天发送超过 100 封电子邮件,请升级到付费套餐或选择其他事务性电子邮件提供商。

    COOKIE_SECRETJWT_SECRET 至少应为 32 个字符长,但越长越好。您可以使用在线密码生成器并将密码长度设置为 32 个或更长。

    MI_AUTH_COOKIE_DOMAIN=localhost
    MI_BACKEND_HOST=https://:3001/
    MI_FRONTEND_HOST=https:///
    

    localhost 替换为 IP 或 FQDN。也就是说,如果您从 http://<FQDN>:3001/ 访问后端。MI_BACKEND_HOST 应为 http://<FQDN>:3001/。MI_FRONTEND_HOST 也是如此。而 MI_AUTH_COOKIE_DOMAIN 应为 FQDN。

    如果您想本地测试,请将 localhost 保留。

    如果您想在移动应用程序中启用推送通知,请遵循这些 说明 并设置以下选项

    MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN 

    如果您想启用 Stripe 支付网关,请注册一个 stripe 账户,填写表格并保存 Stripe 开发者仪表板中的发布密钥和密钥。然后,在 api/.env 中的以下选项中设置密钥

    MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
    

    不要在网站上公开 Stripe 密钥或将其嵌入移动应用程序中。它必须保密并安全地存储在服务器端。

    在 Stripe 中,所有账户默认共有四个 API 密钥 - 两个用于测试模式,两个用于实时模式

    • 测试模式密钥:默认情况下,您可以使用此密钥在测试模式下对服务器上的请求进行身份验证。默认情况下,您可以使用此密钥不受限制地执行任何 API 请求。
    • 测试模式发布密钥:在 Web 或移动应用程序的客户端代码中,可以使用此密钥进行测试。
    • 实时模式密钥:在实时模式下,您可以使用此密钥在服务器上对请求进行身份验证。默认情况下,您可以使用此密钥不受限制地执行任何 API 请求。
    • 实时模式发布密钥:当您准备好启动应用程序时,可以使用此密钥在 Web 或移动应用程序的客户端代码中。

    仅使用您的测试 API 密钥进行测试。这样可以确保您不会意外修改您的实时客户或交易。

  3. 创建 ./backend/.env.docker 文件,内容如下
    VITE_NODE_ENV=production
    VITE_MI_API_HOST=https://:4004
    VITE_MI_DEFAULT_LANGUAGE=en
    VITE_MI_PAGE_SIZE=30
    VITE_MI_PROPERTIES_PAGE_SIZE=15
    VITE_MI_BOOKINGS_PAGE_SIZE=20
    VITE_MI_BOOKINGS_MOBILE_PAGE_SIZE=10
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_TEMP_USERS=https:///cdn/movinin/temp/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_TEMP_PROPERTIES=https:///cdn/movinin/temp/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_CDN_TEMP_LOCATIONS=https:///cdn/movinin/temp/locations
    VITE_MI_AGENCY_IMAGE_WIDTH=60
    VITE_MI_AGENCY_IMAGE_HEIGHT=30
    VITE_MI_PROPERTY_IMAGE_WIDTH=300
    VITE_MI_PROPERTY_IMAGE_HEIGHT=200
    VITE_MI_MINIMUM_AGE=21
    VITE_MI_PAGINATION_MODE=classic 

    设置以下选项

    VITE_MI_API_HOST=https://:4004
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_TEMP_USERS=https:///cdn/movinin/temp/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_TEMP_PROPERTIES=https:///cdn/movinin/temp/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_CDN_TEMP_LOCATIONS=https:///cdn/movinin/temp/locations
    

    如果您想本地测试,请将 localhost 保留,或将其替换为 IP、主机名或 FQDN。

    如果您想更改分页模式,请更改 VITE_MI_PAGINATION_MODE 选项。您可以在 classicinfinite_scroll 之间选择。此选项默认为 classic。如果您选择 classic,在桌面端您将获得经典的带上一页和下一页按钮的分页,在移动端获得无限滚动。如果您选择 infinite_scroll,您将在桌面端和移动端都获得无限滚动。

  4. 创建 ./frontend/.env.docker 文件,内容如下
    VITE_NODE_ENV=production
    VITE_MI_API_HOST=https://:4004
    VITE_MI_RECAPTCHA_ENABLED=false
    VITE_MI_DEFAULT_LANGUAGE=en
    VITE_MI_PAGE_SIZE=30
    VITE_MI_PROPERTIES_PAGE_SIZE=15
    VITE_MI_BOOKINGS_PAGE_SIZE=20
    VITE_MI_BOOKINGS_MOBILE_PAGE_SIZE=10
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_AGENCY_IMAGE_WIDTH=60
    VITE_MI_AGENCY_IMAGE_HEIGHT=30
    VITE_MI_PROPERTY_IMAGE_WIDTH=300
    VITE_MI_PROPERTY_IMAGE_HEIGHT=200
    VITE_MI_MINIMUM_AGE=21
    VITE_MI_PAGINATION_MODE=classic 
    VITE_MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    VITE_MI_STRIPE_CURRENCY_CODE=USD
    VITE_MI_CURRENCY=$
    VITE_MI_SET_LANGUAGE_FROM_IP=false
    VITE_MI_GOOGLE_ANALYTICS_ENABLED=false
    VITE_MI_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXXX
    VITE_MI_FB_APP_ID=XXXXXXXXXX
    VITE_MI_APPLE_ID=XXXXXXXXXX
    VITE_MI_GG_APP_ID=XXXXXXXXXX
    

    设置以下选项

    VITE_MI_API_HOST=https://:4004
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_STRIPE_CURRENCY_CODE=USD
    VITE_MI_CURRENCY=$
    VITE_MI_SET_LANGUAGE_FROM_IP=false
    VITE_MI_GOOGLE_ANALYTICS_ENABLED=false
    VITE_MI_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXXX
    VITE_MI_FB_APP_ID=XXXXXXXXXX
    VITE_MI_APPLE_ID=XXXXXXXXXX
    VITE_MI_GG_APP_ID=XXXXXXXXXX
    

    如果您想本地测试,请将 localhost 保留,或将其替换为 IP、主机名或 FQDN。

    如果您想启用 Stripe 支付网关,请在 VITE_MI_STRIPE_PUBLISHABLE_KEY 中设置 Stripe 发布密钥。您可以从 Stripe 仪表板中检索它。

    VITE_MI_STRIPE_CURRENCY_CODE 是三位字母 ISO 4217 货币代码,例如“USD”或“EUR”。Stripe 支付必需。必须是受支持的货币:https://docs.stripe.com/currencies

    如果您想更改分页模式,请更改 VITE_MI_PAGINATION_MODE 选项。

    reCAPTCHA 默认在前端禁用。如果您想启用它,必须将 VITE_MI_RECAPTCHA_ENABLED 设置为 true,并将 VITE_MI_RECAPTCHA_SITE_KEY 设置为 Google reCAPTCHA 站点密钥。

  5. 打开 ./docker-compose.yml 并设置 MongoDB 密码
    version: "3.8"
    services:
      api:
        build: 
          context: .
          dockerfile: ./api/Dockerfile
        env_file: ./api/.env.docker
        restart: always
        ports:
          - 4004:4004
        depends_on:
          - mongo
        volumes:
          - cdn:/var/www/cdn/movinin
    
      mongo:
        image: mongo:latest
        command: mongod --quiet --logpath /dev/null
        restart: always
        environment:
          # Provide your credentials here
          MONGO_INITDB_ROOT_USERNAME: admin
          MONGO_INITDB_ROOT_PASSWORD: PASSWORD
        ports:
          - 27017:27017
    
      backend:
        build: 
          context: .
          dockerfile: ./backend/Dockerfile
        depends_on:
          - api
        ports:
          - 3003:3003
    
      frontend:
        build: 
          context: .
          dockerfile: ./frontend/Dockerfile
        depends_on:
          - api
        ports:
          - 80:80
        volumes:
          - cdn:/var/www/cdn/movinin
    
    volumes:
      cdn:

    如果您想使用 MongoDB Atlas,请删除 mongo 容器。否则,请将 PASSWORD 替换为您在 ./api/.env.docker 中的 MI_DB_URI 中设置的密码。

  6. 构建并运行 Docker 镜像
    sudo docker compose up

    要在后台运行 compose,请在命令中添加 -d 选项

    sudo docker compose up -d

    如果您想重新构建,请使用以下命令

    docker compose up --build --force-recreate --no-deps api backend frontend

    如果您想查看容器日志进行故障排除,请使用以下命令

    sudo docker compose logs
    

    如果您想在不缓存的情况下重新构建,请使用以下命令

    docker compose build --no-cache api backend frontend
    docker compose up --force-recreate --no-deps api backend frontend

     

就是这样!Movin' In 后端可以从 http://<hostname>:3003 访问,Movin' In 前端可以从 http://<hostname> 访问。

如果您是第一次运行 Movin' In,您将从一个空的数据库开始。因此,您必须通过在 http://<hostname>:3003/sign-up 填写表单来从后端创建管理员。需要配置 SMTP 设置来处理注册。然后,通过打开 backend/src/App.tsx 并注释掉以下行来保护后端

const SignUp = lazy(() => import('./pages/SignUp'))
<Route exact path='/sign-up' element={<Signup />} /> 

您需要重新构建并运行 Docker 镜像

sudo docker compose build --no-cache
sudo docker compose up

创建管理员用户后,请执行以下操作

  • 转到代理机构页面并创建一个或多个代理机构。
  • 转到地点页面并创建一个或多个地点。
  • 转到房产页面并创建一个或多个房产。
  • 转到前端,注册,选择一个房产并结账。

最后,您将在后端仪表板中看到列出的预订。

如果您愿意,可以使用 演示数据库

以下是 Docker 配置文件

就是这样!您可以在后端和前端探索其他页面。

SSL

本节将引导您完成在 Docker 容器中的 API、后端和前端中启用 SSL 的方法。

将您的私钥 movinin.key 和证书 movinin.crt 复制到 ./ 旁边 docker-compose.yml

movinin.key 将被加载为 /etc/ssl/movinin.keymovinin.crt 将被加载为 /etc/ssl/movinin.crt./docker-compose.yml 中。

API

对于 API,更新 ./api/.env.docker 如下以启用 SSL

MI_HTTPS=true
MI_PRIVATE_KEY=/etc/ssl/movinin.key
MI_CERTIFICATE=/etc/ssl/movinin.crt
MI_BACKEND_HOST=https://:3003/
MI_FRONTEND_HOST=https:/// 

https:// 替换为 https://<fqdn>

后端

对于后端,更新 ./backend/.env.docker 中的以下选项

VITE_MI_API_HOST=https://:4004
VITE_MI_CDN_USERS=https:///cdn/movinin/users
VITE_MI_CDN_TEMP_USERS=https:///cdn/movinin/temp/users
VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
VITE_MI_CDN_TEMP_PROPERTIES=https:///cdn/movinin/temp/properties 

https:// 替换为 https://<fqdn>

然后,更新 ./backend/nginx.conf 如下以启用 SSL

server {
    listen 3003 ssl;
    root /usr/share/nginx/html;
    index index.html;

    ssl_certificate_key /etc/ssl/movinin.key;
    ssl_certificate /etc/ssl/movinin.crt;

    error_page 497 301 =307 https://$host:$server_port$request_uri;

    access_log /var/log/nginx/backend.access.log;
    error_log /var/log/nginx/backend.error.log;

    location / {
        # First attempt to serve request as file, then as directory,
        # then as index.html, then fall back to displaying a 404.
        try_files $uri $uri/ /index.html =404;
    }
} 

前端

对于前端,更新 ./frontend/.env.docker 中的以下选项

VITE_MI_API_HOST=https://:4004
VITE_MI_CDN_USERS=https:///cdn/movinin/users
VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties 

https:// 替换为 https://<fqdn>

将端口 443 添加到 ./frontend/Dokerfile 如下

# syntax=docker/dockerfile:1

FROM node:lts-alpine as build
WORKDIR /movinin/frontend
COPY ./frontend ./
COPY ./frontend/.env.docker .env
COPY ./packages /movinin/packages
RUN npm install
RUN npm run build

FROM nginx:stable-alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf -- *
COPY --from=build /movinin/frontend/build .
COPY ./frontend/nginx.conf /etc/nginx/conf.d/default.conf
CMD ["nginx", "-g", "daemon off;"]
EXPOSE 80
EXPOSE 443 

然后,更新 ./frontend/nginx.conf 如下以启用 SSL

server {
    listen 80;
    return 301 https://$host$request_uri;
}
server {
    listen 443 ssl;
    root /usr/share/nginx/html;
    index index.html;

    ssl_certificate_key /etc/ssl/movinin.key;
    ssl_certificate /etc/ssl/movinin.crt;

    access_log /var/log/nginx/frontend.access.log;
    error_log /var/log/nginx/frontend.error.log;

    location / {
      # First attempt to serve request as file, then as directory,
      # then as index.html, then fall back to displaying a 404.
      try_files $uri $uri/ /index.html =404;
    }

    location /cdn {
      alias /var/www/cdn;
    }
} 

docker-compose.yml

更新 ./docker-compose.yml 以加载您的私钥 movinin.key 和证书 movinin.crt,并将端口 443 添加到前端,如下所示

version: "3.8"
services:
  api:
    build: 
      context: .
      dockerfile: ./api/Dockerfile
    env_file: ./api/.env.docker
    restart: always
    ports:
      - 4004:4004
    depends_on:
      - mongo
    volumes:
      - cdn:/var/www/cdn/movinin
      - ./movinin.key:/etc/ssl/movinin.key
      - ./movinin.crt:/etc/ssl/movinin.crt

  mongo:
    image: mongo:latest
    restart: always
    environment:
      # Provide your credentials here
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: PASSWORD
    ports:
      - 27017:27017

  backend:
    build: 
      context: .
      dockerfile: ./backend/Dockerfile
    depends_on:
      - api
    ports:
      - 3003:3003
    volumes:
      - ./movinin.key:/etc/ssl/movinin.key
      - ./movinin.crt:/etc/ssl/movinin.crt

  frontend:
    build: 
      context: .
      dockerfile: ./frontend/Dockerfile
    depends_on:
      - api
    ports:
      - 80:80
      - 443:443
    volumes:
      - cdn:/var/www/cdn/movinin
      - ./movinin.key:/etc/ssl/movinin.key
      - ./movinin.crt:/etc/ssl/movinin.crt

volumes:
  cdn:

重新构建并运行 Docker 镜像

sudo docker compose build --no-cache
sudo docker compose up

设置 Stripe

如果您想启用 Stripe 支付网关,请注册一个 stripe 账户,填写表格并保存 Stripe 开发者仪表板中的发布密钥和密钥。

不要在网站上公开 Stripe 密钥或将其嵌入移动应用程序中。它必须保密并安全地存储在服务器端。

在 Stripe 中,所有账户默认共有四个 API 密钥 - 两个用于测试模式,两个用于实时模式

  • 测试模式密钥:默认情况下,您可以使用此密钥在测试模式下对服务器上的请求进行身份验证。默认情况下,您可以使用此密钥不受限制地执行任何 API 请求。
  • 测试模式发布密钥:在 Web 或移动应用程序的客户端代码中,可以使用此密钥进行测试。
  • 实时模式密钥:在实时模式下,您可以使用此密钥在服务器上对请求进行身份验证。默认情况下,您可以使用此密钥不受限制地执行任何 API 请求。
  • 实时模式发布密钥:当您准备好启动应用程序时,可以使用此密钥在 Web 或移动应用程序的客户端代码中。

您可以在 Stripe 开发者仪表板的 API 密钥页面找到您的密钥和发布密钥。

仅使用您的测试 API 密钥进行测试和开发。这样可以确保您不会意外修改您的实时客户或交易。

在生产环境中,请在 API、后端、前端和移动应用程序中使用 HTTPS 来启用 Stripe 支付网关。

API

api/.env 中的以下选项中设置 Stripe 密钥

MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY

前端

在 frontend/.env 中的以下选项中设置 Stripe 发布密钥和货币

VITE_MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
VITE_MI_STRIPE_CURRENCY_CODE=USD

移动应用

mobile/.env 中设置 Stripe 发布密钥和其他 Stripe 设置

MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
MI_STRIPE_COUNTRY_CODE=US
MI_STRIPE_CURRENCY_CODE=USD

MI_STRIPE_MERCHANT_IDENTIFIER 是您在 Apple 注册以与 Apple Pay 一起使用的商户标识符。

MI_STRIPE_COUNTRY_CODE 是您企业的两字母 ISO 3166 国家代码,例如“US”。Stripe 支付必需。

MI_STRIPE_CURRENCY_CODE 是三位字母 ISO 4217 货币代码,例如“USD”或“EUR”。Stripe 支付必需。必须是受支持的货币:https://docs.stripe.com/currencies

如果您想启用 Apple Pay,还需要在 mobile/app.jsonplugins 部分设置 merchantIdentifier

Google Pay

Google Pay 在 Expo Go 中不受支持。要使用 Google Pay,您必须创建一个 开发版本。这可以通过 EAS Build 来完成,或者通过在本地运行 npx expo run:android 来完成。

Apple Pay

Apple Pay 在 Expo Go 中不受支持。要使用 Apple Pay,您必须创建一个 开发版本。这可以通过 EAS Build 来完成,或者通过在本地运行 npx expo run:ios 来完成。

更改货币

要更改货币,请遵循这些说明

前端

打开 frontend/.env 并更改 VITE_MI_CURRENCYVITE_MI_STRIPE_CURRENCY_CODE 设置。

默认情况下,它设置为

VITE_MI_CURRENCY=$
VITE_MI_STRIPE_CURRENCY_CODE=USD

例如,如果您想更改为欧元

VITE_MI_CURRENCY=€
VITE_MI_STRIPE_CURRENCY_CODE=EUR

在生产环境中,您需要重新构建前端以应用更改。

后端

打开 backend/.env 并更改 VITE_MI_CURRENCY 设置。

默认情况下,它设置为

VITE_MI_CURRENCY=$

在生产环境中,您需要重新构建后端以应用更改。

移动应用

打开 mobile/.env 并更改 MI_CURRENCYMI_STRIPE_CURRENCY_CODE 设置。

默认情况下,它设置为

MI_CURRENCY=$
MI_STRIPE_CURRENCY_CODE=USD

例如,如果您想更改为欧元

MI_CURRENCY=€
MI_STRIPE_CURRENCY_CODE=EUR

在生产环境中,您需要重新构建移动应用程序以应用更改。

添加新语言

要添加新语言,请按以下步骤操作

API

  1. 将新的语言 ISO 639-1 代码 添加到 api/src/config/env.config.ts 中的 LANGUAGES 设置。
  2. src/lang 文件夹中创建一个新文件 <ISO 639-1 code>.ts 并在此文件中添加翻译。
  3. 将您的翻译添加到 src/lang/i18n.ts

后端和前端

  1. 将新的语言 ISO 639-1 代码 及其标签添加到 src/config/env.config.tsLANGUAGES 常量中。
  2. 将翻译添加到 src/lang/*.ts

移动应用

  1. 将新的语言 ISO 639-1 代码 及其标签添加到 config/env.config.tsLANGUAGES 常量中。
  2. lang 文件夹中创建一个新文件 <ISO 639-1 code>.ts 并在此文件中添加翻译。
  3. 将您的翻译添加到 lang/i18n.ts

演示数据库

Windows, Linux 和 macOS

  • 下载并安装 MongoDB 命令行数据库工具
  • 在 Windows 上,将 MongoDB 命令行数据库工具文件夹添加到 Path 环境变量。
  • 下载 movinin-db.zip 到您的本地机器,解压缩并从终端进入解压缩的文件夹。
  • 使用以下命令恢复 Movin' In 演示数据库
    mongorestore --verbose --drop --gzip --host=127.0.0.1 --port=27017 --username=admin --password=$PASSWORD --authenticationDatabase=admin --nsInclude="movinin.*" --archive=movinin.gz

$PASSWORD 替换为您的 MongoDB 密码。

如果您正在使用 MongoDB Atlas,请在 --uri= 命令行参数中放入您的 MongoDB Atlas URI

mongorestore --verbose --drop --gzip --uri=mongodb://admin:$PASSWORD@127.0.0.1:27017/movinin?authSource=admin&appName=movinin --nsInclude="movinin.*" --nsFrom="movinin.*" --nsTo="movinin.*" --archive=movinin.gz

cdn 文件夹的内容复制到您的 Web 服务器,以便可以通过 https:///cdn/movinin/ 访问这些文件。

cdn 文件夹包含以下文件夹

  • users:此文件夹包含用户的头像和代理机构的图像。
  • properties:此文件夹包含房产的图像。
  • temp:此文件夹包含临时文件。

如果您想从源代码运行 Movin' In 或在 Windows 或 Linux 上安装它而不使用 Docker,请按以下步骤操作

  • 在 Windows 上,安装 IIS 并将 cdn 文件夹的内容复制到 C:\inetpub\wwwroot\cdn\movinin。最后,为运行 Movin' In API 的用户授予对 C:\inetpub\wwwroot\cdn\movinin 的完全访问权限。
  • 在 Linux 上,安装 NGINX 并将 cdn 文件夹的内容复制到 /var/www/cdn/movinin。然后,按如下方式更新 /etc/nginx/sites-enabled/default
    server {
        listen 80 default_server;
        server_name _;
        
        ...
    
        location /cdn {
          alias /var/www/cdn;
        }
    } 

    最后,为运行 Movin' In API 的用户授予对 /var/www/cdn/movinin 的完全访问权限。

后端凭据

前端和移动应用程序凭据

Docker

要恢复 Docker 容器中的 Movin' In 演示数据库,请按以下步骤操作

  1. 确保端口 80、3003、4004 和 27017 未被任何应用程序使用。
  2. 在您的本地机器上下载并安装 MongoDB 命令行数据库工具
  3. 在您的本地机器上的 Path 环境变量中添加 MongoDB 命令行数据库工具文件夹。
  4. 下载 movinin-db.zip 到您的本地机器并解压缩。
  5. 运行 compose
    docker compose up
  6. 转到 movinin-db 文件夹并使用以下命令恢复演示数据库
    mongorestore --verbose --drop --gzip --host=127.0.0.1 --port=27017 --username=admin --password=$PASSWORD --authenticationDatabase=admin --nsInclude="movinin.*" --archive=movinin.gz
    $PASSWORD 替换为您在 docker-compose.yml 中设置的 MongoDB 密码。
  7. 使用以下命令获取 API Docker 容器名称
    docker container ls
    名称应类似于:src-api-1
  8. 转到 movinin-db/cdn 文件夹,并使用以下命令将该文件夹的内容复制到 API 容器中
    docker cp ./cdn/users src-api-1:/var/www/cdn/movinin
    docker cp ./cdn/properties src-api-1:/var/www/cdn/movinin
    src-api-1 替换为您的 API 容器名称。
  9. 转到后端 https://:3003 并使用以下凭据登录
    用户名admin@movinin.io
    密码:M00vinin
  10. 转到前端 https:// 并使用以下凭据登录
    用户名jdoe@movinin.io
    密码:M00vinin

构建移动应用

必备组件

要构建 Movin' In 移动应用程序,您需要在您的机器上安装以下工具

使用以下命令安装 eas-cli

npm i -g eas-cli 

配置

  • 您需要下载 google-services.json 文件并将其放在 ./mobile 的根目录中以启用推送通知。否则,移动应用程序将无法构建。请勿忘记在 expo.dev > Credentials > Service Credentials > Google Cloud Messaging Token 中设置 Firebase 服务器密钥,如文档中所述。

  • 如果您没有 Expo 账户,您需要创建一个账户才能构建 Movin' In 移动应用程序。

  • 转到 expo.dev,点击 Projects,然后点击 Create a Project。将 Movin' In 设置为项目名称,然后单击 Create

  • 转到 Movin' In 项目并复制 project ID。打开 ./mobile/app.json 并将 project ID 粘贴到 extra.eas.projectId 中。

  • expo.dev (Account Settings > Access Tokens) 创建一个 Expo Access Token,并在 api/.env 中设置 MI_EXPO_ACCESS_TOKEN 设置。

    MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN 
  • 创建 mobile/.env 文件,内容如下
    MI_API_HOST=https://movinin.io:4004
    MI_DEFAULT_LANGUAGE=en
    MI_PAGE_SIZE=20
    MI_PROPERTIES_PAGE_SIZE=8
    MI_BOOKINGS_PAGE_SIZE=8
    MI_CDN_USERS=https://movinin.io/cdn/movinin/users
    MI_CDN_PROPERTIES=https://movinin.io/cdn/movinin/properties
    MI_AGENCY_IMAGE_WIDTH=60
    MI_AGENCY_IMAGE_HEIGHT=30
    MI_PROPERTY_IMAGE_WIDTH=300
    MI_PROPERTY_IMAGE_HEIGHT=200
    MI_MINIMUM_AGE=21
    MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
    MI_STRIPE_COUNTRY_CODE=US
    MI_STRIPE_CURRENCY_CODE=USD
    

    您需要配置以下选项

    MI_API_HOST=https://movinin.io:4004
    MI_CDN_USERS=https://movinin.io/cdn/movinin/users
    MI_CDN_PROPERTIES=https://movinin.io/cdn/movinin/properties
    MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
    MI_STRIPE_COUNTRY_CODE=US
    MI_STRIPE_CURRENCY_CODE=USD
    

    https://movinin.io 替换为 IP、主机名或 FQDN。

    如果您想启用 Stripe 支付网关,请在 MI_STRIPE_PUBLISHABLE_KEY 中设置 Stripe 发布密钥。您可以从 Stripe 仪表板中检索它。

    MI_STRIPE_MERCHANT_IDENTIFIER 是您在 Apple 注册以与 Apple Pay 一起使用的商户标识符。

    MI_STRIPE_COUNTRY_CODE 是您企业的两字母 ISO 3166 国家代码,例如“US”。Stripe 支付必需。

    MI_STRIPE_CURRENCY_CODE 是三位字母 ISO 4217 货币代码,例如“USD”或“EUR”。Stripe 支付必需。必须是受支持的货币:https://docs.stripe.com/currencies

生产构建

如果您想在生产环境中使用 Movin' In 移动应用程序,您应该在 Movin' In API 中使用 HTTPS,并在 ./mobile/app.json 中禁用 usesCleartextTraffic expo 插件,通过删除 plugins 部分中的行 "./plugins/usesCleartextTraffic" 来实现。

说明

  • 将源代码克隆到您的计算机
    git clone https://github.com/aelassas/movinin.git 
  • 转到 mobile 文件夹
    cd ./mobile 
  • 运行以下命令
    npm install

Android

EAS Build

要使用 EAS Build 托管服务构建 Movin' In Android 应用程序,请运行以下命令

npm run build:android
本地构建

本地构建需要 macOS 或 Linux。对于 Windows,您应该使用 EAS Builds。

您需要安装 Android Studio、openjdk-17,并设置 ANDROID_HOMEJAVA_HOME 环境变量。然后,运行以下命令

npm run build:android:local 

在 macOS 上,如果您在本地构建时遇到问题,请尝试在 ./mobile/eas.json 中设置 ANDROID_HOMEJAVA_HOME 环境变量,如下所示

{
  "cli": {
    "version": ">= 0.53.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "android": {
        "image": "latest",
        "gradleCommand": ":app:assembleDebug"
      },
      "ios": {
        "image": "latest",
        "buildConfiguration": "Debug"
      }
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {
      "env": {
        "ANDROID_HOME": "/path/to/android/sdk",
        "JAVA_HOME": "/path/to/java/home"
      },
      "android": {
        "image": "latest",
        "buildType": "apk"
      },
      "ios": {
        "image": "latest"
      }
    }
  },
  "submit": {
    "production": {}
  }
}

iOS

您需要付费的 Apple 开发者账户才能构建 EAS 构建和本地构建的 iOS 应用程序。

EAS Build

要构建 Movin' In iOS 应用程序,请运行以下命令

npm run build:ios
本地构建

您需要为 macOS 安装 fastlane 和 CocoaPods。

要本地构建 Movin' In iOS 应用程序,请运行以下命令

npm run build:ios:local

从源码运行

以下是从源代码运行 Movin' In 的说明。

必备组件

  1. 安装 gitNode.js、Windows 上的 NGINXIISMongoDBmongosh。如果您想使用 MongoDB Atlas,可以跳过安装和配置 MongoDB。

  2. 配置 MongoDB

    mongosh

    创建管理员用户

    db = db.getSiblingDB('admin')
    db.createUser({ user: "admin" , pwd: "PASSWORD", 
    roles: ["userAdminAnyDatabase", "dbAdminAnyDatabase", "readWriteAnyDatabase"]})

    PASSWORD 替换为强密码。

    通过更改 mongod.conf 以如下方式安全 MongoDB

    net:
      port: 27017
      bindIp: 0.0.0.0
    
    security:
      authorization: enabled

    重启 MongoDB 服务。

说明

  1. 克隆 Movin' In 仓库
    git clone https://github.com/aelassas/movinin.git
  2. 创建 api/.env 文件,内容如下
    NODE_ENV=development
    MI_PORT=4004
    MI_HTTPS=false
    MI_PRIVATE_KEY=/etc/ssl/movinin.key
    MI_CERTIFICATE=/etc/ssl/movinin.crt
    MI_DB_URI=mongodb://admin:PASSWORD@127.0.0.1:27017/movinin?authSource=admin&appName=movinin
    MI_DB_SSL=false
    MI_DB_SSL_CERT=/etc/ssl/movinin.crt
    MI_DB_SSL_CA=/etc/ssl/movinin.ca.pem
    MI_DB_DEBUG=false
    MI_COOKIE_SECRET=COOKIE_SECRET
    MI_AUTH_COOKIE_DOMAIN=localhost
    MI_JWT_SECRET=JWT_SECRET
    MI_JWT_EXPIRE_AT=86400
    MI_TOKEN_EXPIRE_AT=86400
    MI_SMTP_HOST=smtp.sendgrid.net
    MI_SMTP_PORT=587
    MI_SMTP_USER=apikey
    MI_SMTP_PASS=PASSWORD
    MI_SMTP_FROM=admin@movinin.io
    MI_CDN_USERS=/var/www/cdn/movinin/users
    MI_CDN_TEMP_USERS=/var/www/cdn/movinin/temp/users
    MI_CDN_PROPERTIES=/var/www/cdn/movinin/properties
    MI_CDN_TEMP_PROPERTIES=/var/www/cdn/movinin/temp/properties
    MI_CDN_LOCATIONS=/var/www/cdn/movinin/locations
    MI_CDN_TEMP_LOCATIONS=/var/www/cdn/movinin/temp/locations
    MI_DEFAULT_LANGUAGE=en
    MI_BACKEND_HOST=https://:3003/
    MI_FRONTEND_HOST=https://:3004/
    MI_MINIMUM_AGE=21
    MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN
    MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
    MI_ADMIN_EMAIL=admin@movinin.io
    MI_RECAPTCHA_SECRET=RECAPTCHA_SECRET
    

    在 Windows 上,安装 IIS 并使用以下值更新以下设置

    MI_CDN_USERS=C:\inetpub\wwwroot\cdn\movinin\users
    MI_CDN_TEMP_USERS=C:\inetpub\wwwroot\cdn\movinin\temp\users
    MI_CDN_PROPERTIES=C:\inetpub\wwwroot\cdn\movinin\properties
    MI_CDN_TEMP_PROPERTIES=C:\inetpub\wwwroot\cdn\movinin\temp\properties 

    为运行 Movin' In API 的用户授予对 C:\inetpub\wwwroot\cdn\movinin 的完全访问权限。

    您需要配置以下选项

    MI_DB_URI=mongodb://admin:PASSWORD@127.0.0.1:27017/movinin?authSource=admin&appName=movinin
    MI_COOKIE_SECRET=COOKIE_SECRET
    MI_JWT_SECRET=JWT_SECRET
    MI_SMTP_HOST=smtp.sendgrid.net
    MI_SMTP_PORT=587
    MI_SMTP_USER=apikey
    MI_SMTP_PASS=PASSWORD
    MI_SMTP_FROM=admin@movinin.io
    

    如果您想使用 MongoDB Atlas,请将您的 MongoDB Atlas URI 放在 MI_DB_URI 中,否则请将 MI_DB_URI 中的 PASSWORD 替换为您的 MongoDB 密码。将 JWT_SECRET 替换为密钥令牌。最后,设置 SMTP 选项。SMTP 选项对于注册是必需的。您可以使用 sendgrid 或任何其他事务性电子邮件提供商。

    如果您选择 sendgrid,请在 sendgrid.com 上创建一个账户,登录并转到仪表板。在左侧面板中,单击“Email API”,然后单击“Integration Guide”。然后,选择“SMTP Relay”并按照步骤操作。系统会提示您创建一个 API 密钥。一旦您创建了 API 密钥并验证了 smtp 中继,请将 API 密钥复制到 ./api/.env 中的 MI_SMTP_PASS。Sendgrid 的免费套餐允许每天发送最多 100 封电子邮件。如果您需要每天发送超过 100 封电子邮件,请升级到付费套餐或选择其他事务性电子邮件提供商。

    COOKIE_SECRETJWT_SECRET 至少应为 32 个字符长,但越长越好。您可以使用在线密码生成器并将密码长度设置为 32 个或更长。

    如果您想在移动应用程序中启用推送通知,请遵循这些 说明 并设置以下选项

    MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN

    如果您想启用 Stripe 支付网关,请注册一个 stripe 账户,填写表格并保存 Stripe 开发者仪表板中的发布密钥和密钥。然后,在 api/.env 中的以下选项中设置密钥

    MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
    

    不要在网站上公开 Stripe 密钥或将其嵌入移动应用程序中。它必须保密并安全地存储在服务器端。使用 Stripe 的测试模式。

    仅使用您的测试 API 密钥进行测试。这样可以确保您不会意外修改您的实时客户或交易。

    要运行 API,请使用以下命令

    cd ./api
    npm install
    npm run dev
  3. 添加 backend/.env 文件
    VITE_PORT=3003
    VITE_NODE_ENV=development
    VITE_MI_API_HOST=https://:4004
    VITE_MI_DEFAULT_LANGUAGE=en
    VITE_MI_PAGE_SIZE=30
    VITE_MI_PROPERTIES_PAGE_SIZE=15
    VITE_MI_BOOKINGS_PAGE_SIZE=20
    VITE_MI_BOOKINGS_MOBILE_PAGE_SIZE=10
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_TEMP_USERS=https:///cdn/movinin/temp/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_TEMP_PROPERTIES=https:///cdn/movinin/temp/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_CDN_TEMP_LOCATIONS=https:///cdn/movinin/temp/locations
    VITE_MI_AGENCY_IMAGE_WIDTH=60
    VITE_MI_AGENCY_IMAGE_HEIGHT=30
    VITE_MI_PROPERTY_IMAGE_WIDTH=300
    VITE_MI_PROPERTY_IMAGE_HEIGHT=200
    VITE_MI_MINIMUM_AGE=21
    VITE_MI_PAGINATION_MODE=classic 

    要运行后端,请使用以下命令

    cd ./backend
    npm install
    npm start
  4. 添加 frontend/.env 文件
    VITE_PORT=3004
    VITE_NODE_ENV=development
    VITE_MI_API_HOST=https://:4004
    VITE_MI_RECAPTCHA_ENABLED=false
    VITE_MI_RECAPTCHA_SITE_KEY=GOOGLE_RECAPTCHA_SITE_KEY
    VITE_MI_DEFAULT_LANGUAGE=en
    VITE_MI_PAGE_SIZE=30
    VITE_MI_PROPERTIES_PAGE_SIZE=15
    VITE_MI_BOOKINGS_PAGE_SIZE=20
    VITE_MI_BOOKINGS_MOBILE_PAGE_SIZE=10
    VITE_MI_CDN_USERS=https:///cdn/movinin/users
    VITE_MI_CDN_PROPERTIES=https:///cdn/movinin/properties
    VITE_MI_CDN_LOCATIONS=https:///cdn/movinin/locations
    VITE_MI_AGENCY_IMAGE_WIDTH=60
    VITE_MI_AGENCY_IMAGE_HEIGHT=30
    VITE_MI_PROPERTY_IMAGE_WIDTH=300
    VITE_MI_PROPERTY_IMAGE_HEIGHT=200
    VITE_MI_MINIMUM_AGE=21
    VITE_MI_PAGINATION_MODE=classic
    VITE_MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    VITE_MI_STRIPE_CURRENCY_CODE=USD
    VITE_MI_CURRENCY=$
    VITE_MI_SET_LANGUAGE_FROM_IP=false
    VITE_MI_GOOGLE_ANALYTICS_ENABLED=false
    VITE_MI_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXXX
    VITE_MI_FB_APP_ID=XXXXXXXXXX
    VITE_MI_APPLE_ID=XXXXXXXXXX
    VITE_MI_GG_APP_ID=XXXXXXXXXX
    

    如果您想启用 Stripe 支付网关,请在 VITE_MI_STRIPE_PUBLISHABLE_KEY 中设置 Stripe 发布密钥。您可以从 Stripe 仪表板中检索它。

    VITE_MI_STRIPE_CURRENCY_CODE 是三位字母 ISO 4217 货币代码,例如“USD”或“EUR”。Stripe 支付必需。必须是受支持的货币:https://docs.stripe.com/currencies

    reCAPTCHA 默认禁用。如果您想启用它,必须将 VITE_MI_RECAPTCHA_ENABLED 设置为 true,并将 VITE_MI_RECAPTCHA_SITE_KEY 设置为 Google reCAPTCHA 站点密钥。

    要运行前端,请使用以下命令

    cd ./frontend
    npm install
    npm run dev
  5. 如果您想运行移动应用程序,则需要添加 mobile/.env
    MI_API_HOST=https://movinin.io:4004
    MI_DEFAULT_LANGUAGE=en
    MI_PAGE_SIZE=20
    MI_PROPERTIES_PAGE_SIZE=8
    MI_BOOKINGS_PAGE_SIZE=8
    MI_CDN_USERS=https://movinin.io/cdn/movinin/users
    MI_CDN_PROPERTIES=https://movinin.io/cdn/movinin/properties
    MI_AGENCY_IMAGE_WIDTH=60
    MI_AGENCY_IMAGE_HEIGHT=30
    MI_PROPERTY_IMAGE_WIDTH=300
    MI_PROPERTY_IMAGE_HEIGHT=200
    MI_MINIMUM_AGE=21
    MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
    MI_STRIPE_COUNTRY_CODE=US
    MI_STRIPE_CURRENCY_CODE=USD
    

    您需要配置以下选项

    MI_API_HOST=https://movinin.io:4004
    MI_CDN_USERS=https://movinin.io/cdn/movinin/users
    MI_CDN_PROPERTIES=https://movinin.io/cdn/movinin/properties
    MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
    MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
    MI_STRIPE_COUNTRY_CODE=US
    MI_STRIPE_CURRENCY_CODE=USD
    

    您需要将 https://movinin.io 替换为 IP 或主机名。

    如果您想启用 Stripe 支付网关,请在 MI_STRIPE_PUBLISHABLE_KEY 中设置 Stripe 发布密钥。您可以从 Stripe 仪表板中检索它。使用 Stripe 的测试模式。

  6. 配置 https:///cdn
    • 在 Windows 上,安装 IIS,创建 C:\inetpub\wwwroot\cdn\movinin 文件夹,并为运行 Movin' In API 的用户授予对 movininC:\inetpub\wwwroot\cdn\movinin 文件夹的完全访问权限。
    • 在 Linux 上,安装 NGINX,创建 /var/www/cdn/movinin 文件夹,为运行 Movin' In API 的用户授予对 /var/www/cdn/movinin 文件夹的完全访问权限,并通过修改 /etc/nginx/sites-available/defaultcdn 文件夹添加到 NGINX,如下所示
    server {
        listen 80 default_server;
        server_name _;
        
        ...
    
        location /cdn {
          alias /var/www/cdn;
        }
    }
  7. https://:3003/sign-up 创建管理员用户
  8. 要运行移动应用程序,只需在您的设备上下载 Expo 应用程序,然后从 ./mobile 文件夹运行以下命令
    npm install
    npm run dev

您需要下载 google-services.json 文件并将其放在 ./mobile 的根目录中以启用推送通知。

您可以在 此处 找到有关运行移动应用程序的详细说明。

要更改货币,请遵循这些 说明

运行移动应用

要运行移动应用程序,请创建 ./mobile/.env 文件,内容如下

MI_API_HOST=https://movinin.io:4004
MI_DEFAULT_LANGUAGE=en
MI_PAGE_SIZE=20
MI_PROPERTIES_PAGE_SIZE=8
MI_BOOKINGS_PAGE_SIZE=8
MI_CDN_USERS=https://movinin.io/cdn/movinin/users
MI_CDN_PROPERTIES=https://movinin.io/cdn/movinin/properties
MI_AGENCY_IMAGE_WIDTH=60
MI_AGENCY_IMAGE_HEIGHT=30
MI_PROPERTY_IMAGE_WIDTH=300
MI_PROPERTY_IMAGE_HEIGHT=200
MI_MINIMUM_AGE=21
MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
MI_STRIPE_COUNTRY_CODE=US
MI_STRIPE_CURRENCY_CODE=USD

您需要配置以下选项

MI_API_HOST=https://movinin.io:4004
MI_CDN_USERS=https://movinin.io/cdn/movinin/users
MI_CDN_PROPERTIES=https://movinin.io/cdn/movinin/properties
MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
MI_STRIPE_MERCHANT_IDENTIFIER=MERCHANT_IDENTIFIER
MI_STRIPE_COUNTRY_CODE=US
MI_STRIPE_CURRENCY_CODE=USD

https://movinin.io 替换为 IP、主机名或 FQDN。

如果您想启用 Stripe 支付网关,请在 MI_STRIPE_PUBLISHABLE_KEY 中设置 Stripe 发布密钥。您可以从 Stripe 仪表板中检索它。使用 Stripe 的测试模式。

MI_STRIPE_MERCHANT_IDENTIFIER 是您在 Apple 注册以与 Apple Pay 一起使用的商户标识符。

MI_STRIPE_COUNTRY_CODE 是您企业的两字母 ISO 3166 国家代码,例如“US”。Stripe 支付必需。

MI_STRIPE_CURRENCY_CODE 是三位字母 ISO 4217 货币代码,例如“USD”或“EUR”。Stripe 支付必需。必须是受支持的货币:https://docs.stripe.com/currencies

通过遵循这些 说明 来安装演示数据库。

配置 https:///cdn

在 Windows 上,安装 IIS 并为运行 Movin' In API 的用户授予对 C:\inetpub\wwwroot\cdn\movinin 的完全访问权限。

在 Linux 上,安装 NGINX 并按如下方式更新 /etc/nginx/sites-enabled/default

server {
    listen 80 default_server;
    server_name _;
    
    ...

    location /cdn {
      alias /var/www/cdn;
    }
} 

最后,为运行 Movin' In API 的用户授予对 /var/www/cdn/movinin 的完全访问权限。

通过遵循这些 说明 来配置 ./api

使用以下命令运行 ./api

cd ./api
npm run dev 

通过在您的设备上下载 Expo 应用程序并从 ./mobile 文件夹运行以下命令来运行移动应用程序

cd ./mobile
npm install
npm start 

打开设备上的 Expo 应用程序并扫描 QR 码以运行 Movin' In 移动应用程序。

推送通知

如果您想启用 BookCars 推送通知,请下载 google-services.json 文件并将其放在 ./mobile 的根目录中以启用推送通知。其路径可以在 ./mobile/app.json 配置文件中通过 googleServicesFile 设置选项进行配置。不要忘记在 Firebase Console 中生成一个新的 私钥,并将 JSON 文件上传到 Expo 仪表板

单元测试和覆盖率

以下是运行单元测试和构建覆盖率报告的说明。

必备组件

  1. 安装 gitNode.js、NGINX 或 IISMongoDBmongosh

  2. 配置 MongoDB

    mongosh
    
  3. 创建管理员用户

    db = db.getSiblingDB('admin')
    db.createUser({ user: "admin" , pwd: "PASSWORD", roles: ["userAdminAnyDatabase", "dbAdminAnyDatabase", "readWriteAnyDatabase"]})
    

    PASSWORD 替换为强密码。

  4. 通过更改 mongod.conf 以如下方式安全 MongoDB

    net:
    	port: 27017
    	bindIp: 0.0.0.0
    
    security:
    	authorization: enabled
    
  5. 重启 MongoDB 服务。

说明

  1. 克隆 Movin' In 仓库
    git clone https://github.com/aelassas/movinin.git
    
  1. 创建 api/.env 文件,内容如下
    NODE_ENV=development
    MI_PORT=4004
    MI_HTTPS=false
    MI_PRIVATE_KEY=/etc/ssl/movinin.key
    MI_CERTIFICATE=/etc/ssl/movinin.crt
    MI_DB_URI=mongodb://admin:PASSWORD@127.0.0.1:27017/movinin?authSource=admin&appName=movinin
    MI_DB_SSL=false
    MI_DB_SSL_CERT=/etc/ssl/movinin.crt
    MI_DB_SSL_CA=/etc/ssl/movinin.ca.pem
    MI_DB_DEBUG=false
    MI_COOKIE_SECRET=COOKIE_SECRET
    MI_AUTH_COOKIE_DOMAIN=localhost
    MI_JWT_SECRET=JWT_SECRET
    MI_JWT_EXPIRE_AT=86400
    MI_TOKEN_EXPIRE_AT=86400
    MI_SMTP_HOST=in-v3.mailjet.com
    MI_SMTP_PORT=587
    MI_SMTP_USER=USER
    MI_SMTP_PASS=PASSWORD
    MI_SMTP_FROM=admin@movinin.io
    MI_CDN_USERS=/var/www/cdn/movinin/users
    MI_CDN_TEMP_USERS=/var/www/cdn/movinin/temp/users
    MI_CDN_PROPERTIES=/var/www/cdn/movinin/properties
    MI_CDN_TEMP_PROPERTIES=/var/www/cdn/movinin/temp/properties
    MI_DEFAULT_LANGUAGE=en
    MI_BACKEND_HOST=https://:3003/
    MI_FRONTEND_HOST=https://:3004/
    MI_MINIMUM_AGE=21
    MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN
    MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
    

在 Windows 上,安装 IIS 并使用以下值更新以下设置

MI_CDN_USERS=C:\inetpub\wwwroot\cdn\movinin\users
MI_CDN_TEMP_USERS=C:\inetpub\wwwroot\cdn\movinin\temp\users
MI_CDN_PROPERTIES=C:\inetpub\wwwroot\cdn\movinin\properties
MI_CDN_TEMP_PROPERTIES=C:\inetpub\wwwroot\cdn\movinin\temp\properties

为运行 Movin' In API 的用户授予对 C:\inetpub\wwwroot\cdn\movinin 的完全访问权限。

然后,设置以下选项

MI_DB_URI=mongodb://admin:PASSWORD@127.0.0.1:27017/movinin?authSource=admin&appName=movinin
MI_COOKIE_SECRET=COOKIE_SECRET
MI_JWT_SECRET=JWT_SECRET
MI_SMTP_HOST=in-v3.mailjet.com
MI_SMTP_PORT=587
MI_SMTP_USER=USER
MI_SMTP_PASS=PASSWORD
MI_SMTP_FROM=admin@movinin.io
MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY

MI_DB_URI 中的 PASSWORD 替换为 MongoDB 密码,将 JWT_SECRET 替换为密钥令牌。最后,设置 SMTP 选项。SMTP 选项对于注册是必需的。您可以使用 mailjet、sendgrid、brevo 或任何其他事务性电子邮件提供商。

STRIPE_SECRET_KEY 替换为您测试模式下的 Stripe 密钥。您可以在 Stripe 开发者仪表板中找到它。

COOKIE_SECRETJWT_SECRET 至少应为 32 个字符长,但越长越好。您可以使用在线密码生成器并将密码长度设置为 32 个或更长。

如果您想在移动应用程序中启用推送通知,请遵循这些 说明 并设置以下选项

MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN

运行 Movin' In API 单元测试

cd ./api
npm install
npm test

覆盖率

运行单元测试后,会自动在以下位置构建覆盖率报告

./api/coverage

您也可以在 codecov 上查看覆盖率报告。

日志

所有 API 日志都写入 ./api/logs/all.log

API 错误日志也写入 ./api/logs/error.log

Using the Code

本节介绍了 Movin' In 的软件架构,包括 API、前端、移动应用程序和后端。

Movin' In API 是一个 Node.js 服务器应用程序,它使用 Express 公开 RESTful API,该 API 提供对 Movin' In MongoDB 数据库的访问。

Movin' In 前端是一个 React Web 应用程序,是用于预订房产的主要 Web 界面。

Movin' In 后端是一个 React Web 应用程序,允许管理员和代理机构管理房产、预订和客户。

Movin' In 移动应用程序是一个 React Native 应用程序,是用于预订房产的主要移动应用程序。

一项重要的设计决策是使用 TypeScript 而非 JavaScript,因为其具有诸多优势。TypeScript 提供强类型、强大的工具和集成,从而实现高质量、可扩展、更易读、更易维护且易于调试和测试的代码。

API

Movin' In API 公开了后端、前端和移动应用程序所需的所有 Movin' In 功能。API 遵循 MVC 设计模式。JWT 用于身份验证。有些函数需要身份验证,例如与管理房产、预订和客户相关的函数;其他函数不需要身份验证,例如检索非认证用户的地点和可用房产。

  • ./api/src/models/ 文件夹包含 MongoDB 模型。
  • ./api/src/routes/ 文件夹包含 Express 路由。
  • ./api/src/controllers/ 文件夹包含控制器。
  • ./api/src/middlewares/ 文件夹包含中间件。
  • ./api/src/config/env.config.ts 包含配置和 TypeScript 类型定义。
  • ./api/src/lang/ 文件夹包含本地化。
  • ./api/src/app.ts 是加载路由的主服务器。
  • ./api/index.ts 是 Movin' In API 的主入口点。

index.ts

index.ts 是 Movin' In API 的主入口点

import 'dotenv/config'
import process from 'node:process'
import fs from 'node:fs/promises'
import http from 'node:http'
import https from 'node:https'
import app from './app'
import * as DatabaseHelper from './common/DatabaseHelper'
import * as env from './config/env.config'

if (await DatabaseHelper.Connect(env.DB_DEBUG)) {
    let server: http.Server | https.Server

    if (env.HTTPS) {
        https.globalAgent.maxSockets = Number.POSITIVE_INFINITY
        const privateKey = await fs.readFile(env.PRIVATE_KEY, 'utf8')
        const certificate = await fs.readFile(env.CERTIFICATE, 'utf8')
        const credentials = { key: privateKey, cert: certificate }
        server = https.createServer(credentials, app)

        server.listen(env.PORT, () => {
            console.log('HTTPS server is running on Port', env.PORT)
        })
    } else {
        server = app.listen(env.PORT, () => {
            console.log('HTTP server is running on Port', env.PORT)
        })
    }

    const close = () => {
        console.log('\nGracefully stopping...')
        server.close(async () => {
            console.log(`HTTP${env.HTTPS ? 'S' : ''} server closed`)
            await DatabaseHelper.Close(true)
            console.log('MongoDB connection closed')
            process.exit(0)
        })
    }

    ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => process.on(signal, close))
}

这是一个 TypeScript 文件,使用 Node.js 和 Express 启动服务器。它导入了包括 dotenv、process、fs、http、https、mongoose 和 app 在内的多个模块。然后,它检查 HTTPS 环境变量是否设置为 true,如果是,则使用 https 模块和提供的私钥及证书创建一个 HTTPS 服务器。否则,它使用 http 模块创建一个 HTTP 服务器。服务器监听 PORT 环境变量中指定的端口。

定义了 close 函数,以便在接收到终止信号时优雅地停止服务器。它会关闭服务器和 MongoDB 连接,然后以状态码 0 退出进程。最后,它注册 close 函数,以便在进程接收到 SIGINTSIGTERMSIGQUIT 信号时调用。

app.ts

app.ts 是主服务器

import express, { Express } from 'express'
import compression from 'compression'
import helmet from 'helmet'
import nocache from 'nocache'
import cookieParser from 'cookie-parser'
import strings from './config/app.config'
import * as env from './config/env.config'
import cors from './middlewares/cors'
import allowedMethods from './middlewares/allowedMethods'
import agencyRoutes from './routes/agencyRoutes'
import bookingRoutes from './routes/bookingRoutes'
import locationRoutes from './routes/locationRoutes'
import notificationRoutes from './routes/notificationRoutes'
import propertyRoutes from './routes/propertyRoutes'
import userRoutes from './routes/userRoutes'
import * as Helper from './common/Helper'

const app: Express = express()

app.use(helmet.contentSecurityPolicy())
app.use(helmet.dnsPrefetchControl())
app.use(helmet.crossOriginEmbedderPolicy())
app.use(helmet.frameguard())
app.use(helmet.hidePoweredBy())
app.use(helmet.hsts())
app.use(helmet.ieNoOpen())
app.use(helmet.noSniff())
app.use(helmet.permittedCrossDomainPolicies())
app.use(helmet.referrerPolicy())
app.use(helmet.xssFilter())
app.use(helmet.originAgentCluster())
app.use(helmet.crossOriginResourcePolicy({ policy: 'cross-origin' }))
app.use(helmet.crossOriginOpenerPolicy())

app.use(nocache())
app.use(compression({ threshold: 0 }))
app.use(express.urlencoded({ limit: '50mb', extended: true }))
app.use(express.json({ limit: '50mb' }))

app.use(cors())
app.options('*', cors())
app.use(cookieParser(env.COOKIE_SECRET))
app.use(allowedMethods)

app.use('/', agencyRoutes)
app.use('/', bookingRoutes)
app.use('/', locationRoutes)
app.use('/', notificationRoutes)
app.use('/', propertyRoutes)
app.use('/', userRoutes)

strings.setLanguage(env.DEFAULT_LANGUAGE)

Helper.mkdir(env.CDN_USERS)
Helper.mkdir(env.CDN_TEMP_USERS)
Helper.mkdir(env.CDN_PROPERTIES)
Helper.mkdir(env.CDN_TEMP_PROPERTIES)

export default app

首先,我们检索 MongoDB 连接字符串,然后与 Movin' In MongoDB 数据库建立连接。然后,我们创建一个 Express app 并加载 cors、compression、helmet 和 nocache 等中间件。我们使用 helmet 中间件库设置了各种安全措施。我们还导入了应用程序不同部分的各种路由文件,例如 supplierRoutesbookingRouteslocationRoutesnotificationRoutespropertyRoutesuserRoutes。最后,我们加载了 Express 路由并将 app 导出。

路由

Movin' In API 中有六个路由。每个路由都有自己的控制器,遵循 MVC 设计模式和 SOLID 原则。以下是主要路由

  • userRoutes:提供与用户相关的 REST 函数
  • agencyRoutes:提供与代理机构相关的 REST 函数
  • locationRoutes:提供与地点相关的 REST 函数
  • propertyRoutes:提供与房产相关的 REST 函数
  • bookingRoutes:提供与预订相关的 REST 函数
  • notificationRoutes:提供与通知相关的 REST 函数

以下是 Property 类型

import { Document, Types } from 'mongoose'

export interface Property extends Document {
    name: string
    type: movininTypes.PropertyType
    agency: Types.ObjectId
    description: string
    image: string
    images?: string[]
    bedrooms: number
    bathrooms: number
    kitchens?: number
    parkingSpaces?: number,
    size?: number
    petsAllowed: boolean
    furnished: boolean
    minimumAge: number
    location: Types.ObjectId
    address?: string
    price: number
    hidden?: boolean
    cancellation?: number
    aircon?: boolean
    available?: boolean
    rentalTerm: movininTypes.RentalTerm
} 

一个房产由以下部分组成:

  • 名称
  • 类型(ApartmentCommercialFarmHouseIndustrialPlotTownhouse
  • 创建它的代理机构的引用
  • 描述
  • 主图
  • 附加图片
  • 卧室数量
  • 浴室数量
  • 厨房数量
  • 停车位数量
  • 大小
  • 租赁最低年龄
  • 地点
  • 地址(可选)
  • 价格
  • 租赁期限(MonthlyWeeklyDailyYearly
  • 取消价格(设置为 0 表示免费包含,留空表示不包含,或设置取消价格)
  • 指示是否允许宠物的标志
  • 指示房产是否带家具的标志
  • 指示房产是否隐藏的标志
  • 指示是否有空调的标志
  • 指示房产是否可供租赁的标志

以下是 create 控制器函数

export const create = async (req: Request, res: Response) => {
  const body: movininTypes.CreatePropertyPayload = req.body

  try {
    if (!body.image) {
      console.error(`[property.create] ${strings.PROPERTY_IMAGE_REQUIRED} ${body}`)
      return res.status(400).send(strings.PROPERTY_IMAGE_REQUIRED)
    }

    const {
      name,
      type,
      agency,
      description,
      image: imageFile,
      images,
      bedrooms,
      bathrooms,
      kitchens,
      parkingSpaces,
      size,
      petsAllowed,
      furnished,
      minimumAge,
      location,
      address,
      price,
      hidden,
      cancellation,
      aircon,
      rentalTerm
    } = body

    const _property = {
      name,
      type,
      agency,
      description,
      bedrooms,
      bathrooms,
      kitchens,
      parkingSpaces,
      size,
      petsAllowed,
      furnished,
      minimumAge,
      location,
      address,
      price,
      hidden,
      cancellation,
      aircon,
      rentalTerm
    }

    const property = new Property(_property)
    await property.save()

    // image
    if (!await Helper.exists(env.CDN_PROPERTIES)) {
      await fs.mkdir(env.CDN_PROPERTIES, { recursive: true })
    }

    const _image = path.join(env.CDN_TEMP_PROPERTIES, imageFile)
    if (await Helper.exists(_image)) {
      const filename = `${property._id}_${Date.now()}${path.extname(imageFile)}`
      const newPath = path.join(env.CDN_PROPERTIES, filename)

      await fs.rename(_image, newPath)
      property.image = filename
    } else {
      await Property.deleteOne({ _id: property._id })
      const err = 'Image file not found'
      console.error(strings.ERROR, err)
      return res.status(400).send(strings.ERROR + err)
    }

    // images
    property.images = []
    if (images) {
      for (let i = 0; i < images.length; i++) {
        const imageFile = images[i]
        const _image = path.join(env.CDN_TEMP_PROPERTIES, imageFile)

        if (await Helper.exists(_image)) {
          const filename = 
           `${property._id}_${uuid()}_${Date.now()}_${i}${path.extname(imageFile)}`
          const newPath = path.join(env.CDN_PROPERTIES, filename)

          await fs.rename(_image, newPath)
          property.images.push(filename)
        } else {
          await Property.deleteOne({ _id: property._id })
          const err = 'Image file not found'
          console.error(strings.ERROR, err)
          return res.status(400).send(strings.ERROR + err)
        }
      }
    }

    await property.save()

    return res.json(property)
  } catch (err) {
    console.error(`[property.create] ${strings.DB_ERROR} ${body}`, err)
    return res.status(400).send(strings.ERROR + err)
  }
}

在此函数中,我们从 HTTP 请求体创建房产,并构建主图以及附加图。

以下是 Booking 类型

import { Document, Types } from 'mongoose'

export interface Booking extends Document {
    agency: Types.ObjectId
    location: Types.ObjectId
    property: Types.ObjectId
    renter: Types.ObjectId
    from: Date
    to: Date
    status: movininTypes.BookingStatus
    cancellation?: boolean
    cancelRequest?: boolean
    price: number
}

一个预订由以下部分组成:

  • 与预订相关的代理机构的引用
  • 与预订相关的地点的引用
  • 与预订相关的房产的引用
  • 与预订相关的客户的引用
  • 租赁开始日期
  • 租赁结束日期
  • 价格
  • 状态(VoidPendingDepositPaidReservedCancelled
  • 指示是否可取消的标志
  • 指示客户是否已发出取消请求的标志

以下是 create 控制器函数

export const create = async (req: Request, res: Response) => {
  try {
    const body: movininTypes.Booking = req.body
    const booking = new Booking(body)

    await booking.save()
    return res.json(booking)
  } catch (err) {
    console.error(`[booking.create]  ${strings.DB_ERROR} ${req.body}`, err)
    return res.status(400).send(strings.DB_ERROR + err)
  }
}

在此函数中,我们根据 HTTP 请求体创建一个 Booking

以下是 update 控制器函数

const notifyRenter = async (booking: env.Booking) => {
  const renter = await User.findById(booking.renter)
  if (!renter) {
    console.log(`Renter ${booking.renter} not found`)
    return
  }
  if (renter.language) {
    strings.setLanguage(renter.language)
  }

  const message = `${strings.BOOKING_UPDATED_NOTIFICATION_PART1} ${booking._id} 
                   ${strings.BOOKING_UPDATED_NOTIFICATION_PART2}`
  const notification = new Notification({
    user: renter._id,
    message,
    booking: booking._id,
  })
  await notification.save()

  let counter = await NotificationCounter.findOne({ user: renter._id })
  if (counter && typeof counter.count !== 'undefined') {
    counter.count++
    await counter.save()
  } else {
    counter = new NotificationCounter({ user: renter._id, count: 1 })
    await counter.save()
  }

  // mail
  const mailOptions = {
    from: env.SMTP_FROM,
    to: renter.email,
    subject: message,
    html: `<p>${strings.HELLO}${renter.fullName},<br><br>
    ${message}<br><br>
    ${Helper.joinURL(env.FRONTEND_HOST, `booking?b=${booking._id}`)}<br><br>
    ${strings.REGARDS}<br></p>`,
  }
  await MailHelper.sendMail(mailOptions)

  // push notification
  const pushNotification = await PushNotification.findOne({ user: renter._id })
  if (pushNotification) {
    const pushToken = pushNotification.token
    const expo = new Expo({ accessToken: env.EXPO_ACCESS_TOKEN })

    if (!Expo.isExpoPushToken(pushToken)) {
      console.log(`Push token ${pushToken} is not a valid Expo push token.`)
      return
    }

    const messages: ExpoPushMessage[] = [
      {
        to: pushToken,
        sound: 'default',
        body: message,
        data: {
          user: renter._id,
          notification: notification._id,
          booking: booking._id,
        },
      },
    ]

    // The Expo push notification service accepts batches of notifications so
    // that you don't need to send 1000 requests to send 1000 notifications. We
    // recommend you batch your notifications to reduce the number of requests
    // and to compress them (notifications with similar content will get
    // compressed).
    const chunks = expo.chunkPushNotifications(messages)
    const tickets = [];

    (async () => {
      // Send the chunks to the Expo push notification service. There are
      // different strategies you could use. A simple one is to send one chunk at a
      // time, which nicely spreads the load out over time:
      for (const chunk of chunks) {
        try {
          const ticketChunk = await expo.sendPushNotificationsAsync(chunk)

          tickets.push(...ticketChunk)
          // NOTE: If a ticket contains an error code in ticket.details.error, you
          // must handle it appropriately. The error codes are listed in the Expo
          // documentation:
          // https://docs.expo.io/push-notifications/sending-notifications/#individual-errors
        } catch (error) {
          console.error(error)
        }
      }
    })()
  }
}

export const update = async (req: Request, res: Response) => {
  try {
    const body: movininTypes.Booking = req.body
    const booking = await Booking.findById(body._id)

    if (booking) {
      const {
        agency,
        location,
        property,
        renter,
        from,
        to,
        status,
        cancellation,
        price,
      } = req.body

      const previousStatus = booking.status

      booking.agency = agency
      booking.location = location
      booking.property = property
      booking.renter = renter
      booking.from = from
      booking.to = to
      booking.status = status
      booking.cancellation = cancellation
      booking.price = price

      await booking.save()

      if (previousStatus !== status) {
        // notify renter
        await notifyRenter(booking)
      }

      return res.sendStatus(200)
    } else {
      console.error('[booking.update] Booking not found:', req.body._id)
      return res.sendStatus(204)
    }
  } catch (err) {
    console.error(`[booking.update]  ${strings.DB_ERROR} ${req.body}`, err)
    return res.status(400).send(strings.DB_ERROR + err)
  }
}

在此函数中,我们根据 HTTP 请求体更新 Booking,并通过电子邮件和推送通知通知客户。

我们不会逐一解释每个路由。我们将以 locationRoutes 为例,看看它是如何创建的

import express from 'express'
import routeNames from '../config/locationRoutes.config'
import authJwt from '../middlewares/authJwt'
import * as locationController from '../controllers/locationController'

const routes = express.Router()

routes
.route(routeNames.validate)
.post(authJwt.verifyToken, locationController.validate)

routes
.route(routeNames.create)
.post(authJwt.verifyToken, locationController.create)

routes
.route(routeNames.update)
.put(authJwt.verifyToken, locationController.update)

routes
.route(routeNames.delete)
.delete(authJwt.verifyToken, locationController.deleteLocation)

routes
.route(routeNames.getLocation)
.get(locationController.getLocation)

routes
.route(routeNames.getLocations)
.get(locationController.getLocations)

routes
.route(routeNames.checkLocation)
.get(authJwt.verifyToken, locationController.checkLocation)

export default routes

首先,我们创建一个 Express Router。然后,我们使用它们的名称、方法、中间件和控制器创建路由。

routeNames 包含 locationRoutes 的路由名称

export default {
    validate: '/api/validate-location',
    create: '/api/create-location',
    update: '/api/update-location/:id',
    delete: '/api/delete-location/:id',
    getLocation: '/api/location/:id/:language',
    getLocations: '/api/locations/:page/:size/:language',
    checkLocation: '/api/check-location/:id',
}

locationController 包含与地点相关的主要业务逻辑。由于控制器相当大,我们不会查看其所有源代码,但我们将以 creategetLocations 控制器函数为例。

以下是 Location 模型

import { Schema, model } from 'mongoose'
import * as env from '../config/env.config'

const locationSchema = new Schema<env.Location>(
  {
    values: {
      type: [Schema.Types.ObjectId],
      ref: 'LocationValue',
      validate: (value: any): boolean => Array.isArray(value) && value.length > 1,
    },
  },
  {
    timestamps: true,
    strict: true,
    collection: 'Location',
  },
)

const locationModel = model<env.Location>('Location', locationSchema)

locationModel.on('index', (err) => {
  if (err) {
    console.error('Location index error: %s', err)
  } else {
    console.info('Location indexing complete')
  }
})

export default locationModel

以下是 env.Location TypeScript 类型

export interface Location extends Document {
    values: Types.ObjectId[]
    name?: string
}

一个 Location 有多个值。每个语言一个。默认支持英语和法语。

以下是 LocationValue 模型

import { Schema, model } from 'mongoose'
import * as env from '../config/env.config'

const locationValueSchema = new Schema<env.LocationValue>(
  {
    language: {
      type: String,
      required: [true, "can't be blank"],
      index: true,
      trim: true,
      lowercase: true,
      minLength: 2,
      maxLength: 2,
    },
    value: {
      type: String,
      required: [true, "can't be blank"],
      index: true,
      trim: true,
    },
  },
  {
    timestamps: true,
    strict: true,
    collection: 'LocationValue',
  },
)

const locationValueModel = model<env.LocationValue>('LocationValue', locationValueSchema)

locationValueModel.on('index', (err) => {
  if (err) {
    console.error('LocationValue index error: %s', err)
  } else {
    console.info('LocationValue indexing complete')
  }
})

export default locationValueModel

以下是 env.LocationValue TypeScript 类型

export interface LocationValue extends Document {
    language: string
    value: string
}

一个 LocationValue 具有 language 代码(ISO 639-1)和一个字符串 value

以下是 create 控制器函数

export const create = async (req: Request, res: Response) => {
  const body: movininTypes.LocationName[] = req.body
  const names = body

  try {
    const values = []
    for (let i = 0; i < names.length; i++) {
      const name = names[i]
      const locationValue = new LocationValue({
        language: name.language,
        value: name.name,
      })
      await locationValue.save()
      values.push(locationValue._id)
    }

    const location = new Location({ values })
    await location.save()
    return res.sendStatus(200)
  } catch (err) {
    console.error(`[location.create] ${strings.DB_ERROR} ${req.body}`, err)
    return res.status(400).send(strings.DB_ERROR + err)
  }
}

在此函数中,我们检索请求体,遍历体中提供的值(每个语言一个值),然后创建一个 LocationValue。最后,我们根据创建的地点值创建地点。

以下是 getLocations 控制器函数

export const getLocations = async (req: Request, res: Response) => {
  try {
    const page = Number.parseInt(req.params.page)
    const size = Number.parseInt(req.params.size)
    const language = req.params.language
    const keyword = escapeStringRegexp(String(req.query.s || ''))
    const options = 'i'

    const locations = await Location.aggregate(
      [
        {
          $lookup: {
            from: 'LocationValue',
            let: { values: '$values' },
            pipeline: [
              {
                $match: {
                  $and: [
                    { $expr: { $in: ['$_id', '$$values'] } },
                    { $expr: { $eq: ['$language', language] } },
                    { $expr: { $regexMatch: { input: '$value', regex: keyword, options } } },
                  ],
                },
              },
            ],
            as: 'value',
          },
        },
        { $unwind: { path: '$value', preserveNullAndEmptyArrays: false } },
        { $addFields: { name: '$value.value' } },
        {
          $facet: {
            resultData: [{ $sort: { name: 1 } }, 
                         { $skip: (page - 1) * size }, { $limit: size }],
            pageInfo: [
              {
                $count: 'totalRecords',
              },
            ],
          },
        },
      ],
      { collation: { locale: env.DEFAULT_LANGUAGE, strength: 2 } },
    )

    return res.json(locations)
  } catch (err) {
    console.error(`[location.getLocations] ${strings.DB_ERROR} ${req.query.s}`, err)
    return res.status(400).send(strings.DB_ERROR + err)
  }
}

在此控制器函数中,我们使用 MongoDB 的 aggregate 函数和 facet 从数据库检索地点以实现分页。

以下是另一个简单的路由,notificationRoutes

import express from 'express'
import routeNames from '../config/notificationRoutes.config'
import authJwt from '../middlewares/authJwt'
import * as notificationController from '../controllers/notificationController'

const routes = express.Router()

routes
.route(routeNames.notificationCounter)
.get(authJwt.verifyToken, notificationController.notificationCounter)

routes
.route(routeNames.notify)
.post(authJwt.verifyToken, notificationController.notify)

routes
.route(routeNames.getNotifications)
.get(authJwt.verifyToken, notificationController.getNotifications)

routes
.route(routeNames.markAsRead)
.post(authJwt.verifyToken, notificationController.markAsRead)

routes
.route(routeNames.markAsUnRead)
.post(authJwt.verifyToken, notificationController.markAsUnRead)

routes
.route(routeNames.delete)
.post(authJwt.verifyToken, notificationController.deleteNotifications)

export default routes

以下是 Notification 模型

import { Schema, model } from 'mongoose'
import * as env from '../config/env.config'

const notificationSchema = new Schema<env.Notification>(
  {
    user: {
      type: Schema.Types.ObjectId,
      required: [true, "can't be blank"],
      ref: 'User',
      index: true,
    },
    message: {
      type: String,
      required: [true, "can't be blank"],
    },
    booking: {
      type: Schema.Types.ObjectId,
      ref: 'Booking',
    },
    isRead: {
      type: Boolean,
      default: false,
    },
  },
  {
    timestamps: true,
    strict: true,
    collection: 'Notification',
  },
)

const notificationModel = model<env.Notification>('Notification', notificationSchema)

notificationModel.on('index', (err) => {
  if (err) {
    console.error('Notification index error: %s', err)
  } else {
    console.info('Notification indexing complete')
  }
})

export default notificationModel 

以下是 env.Notification TypeScript 类型

export interface Notification extends Document {
    user: Types.ObjectId
    message: string
    booking: Types.ObjectId
    isRead?: boolean
}

一个 Notification 由对 user 的引用、一个 message、对 booking 的引用以及 isRead 标志组成。

以下是 getNotifications 控制器函数

export const getNotifications = async (req: Request, res: Response) => {
  const { userId: _userId, page: _page, size: _size } = req.params

  try {
    const userId = new mongoose.Types.ObjectId(_userId)
    const page = Number.parseInt(_page)
    const size = Number.parseInt(_size)

    const notifications = await Notification.aggregate([
      { $match: { user: userId } },
      {
        $facet: {
          resultData: [{ $sort: { createdAt: -1 } }, 
                       { $skip: (page - 1) * size }, { $limit: size }],
          pageInfo: [
            {
              $count: 'totalRecords',
            },
          ],
        },
      },
    ])

    return res.json(notifications)
  } catch (err) {
    console.error(`[notification.getNotifications] ${strings.DB_ERROR} ${_userId}`, err)
    return res.status(400).send(strings.DB_ERROR + err)
  }
}

在此简单的控制器函数中,我们使用 MongoDB 的 aggregate 函数、pagesize 参数检索通知。

以下是 markAsRead 控制器函数

export const markAsRead = async (req: Request, res: Response) => {
  try {
    const body: { ids: string[] } = req.body
    const { ids: _ids } = body
    const ids = _ids.map((id) => new mongoose.Types.ObjectId(id))
    const { userId: _userId } = req.params
    const userId = new mongoose.Types.ObjectId(_userId)

    const bulk = Notification.collection.initializeOrderedBulkOp()
    const notifications = await Notification.find({
      _id: { $in: ids },
      isRead: false,
    })
    const length = notifications.length

    bulk.find({ _id: { $in: ids }, isRead: false }).update({ $set: { isRead: true } })
    const result = await bulk.execute()

    if (result.modifiedCount !== length) {
      console.error(`[notification.markAsRead] ${strings.DB_ERROR}`)
      return res.status(400).send(strings.DB_ERROR)
    }
    const counter = await NotificationCounter.findOne({ user: userId })
    if (!counter || typeof counter.count === 'undefined') {
      return res.sendStatus(204)
    }
    counter.count -= length
    await counter.save()

    return res.sendStatus(200)
  } catch (err) {
    console.error(`[notification.markAsRead] ${strings.DB_ERROR}`, err)
    return res.status(400).send(strings.DB_ERROR + err)
  }
}

在此控制器函数中,我们批量更新通知并将其标记为已读。

前端

前端是一个使用 Node.js、React、MUI 和 TypeScript 构建的 Web 应用程序。通过前端,客户可以根据地点和时间搜索可用房产,选择房产并进行结账。

  • ./frontend/src/assets/ 文件夹包含 CSS 和图像。
  • ./frontend/src/pages/ 文件夹包含 React 页面。
  • ./frontend/src/components/ 文件夹包含 React 组件。
  • ./frontend/src/services/ 包含 Movin' In API 客户端服务。
  • ./frontend/src/App.tsx 是包含路由的主 React App。
  • ./frontend/src/index.tsx 是前端的主入口点。

TypeScript 类型定义在包 ./packages/movinin-types 中定义。

App.tsx 是主 React App

import React, { lazy, Suspense } from 'react'
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'

const SignIn = lazy(() => import('./pages/SignIn'))
const SignUp = lazy(() => import('./pages/SignUp'))
const Activate = lazy(() => import('./pages/Activate'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
const Home = lazy(() => import('./pages/Home'))
const Properties = lazy(() => import('./pages/Properties'))
const Property = lazy(() => import('./pages/Property'))
const Checkout = lazy(() => import('./pages/Checkout'))
const Bookings = lazy(() => import('./pages/Bookings'))
const Booking = lazy(() => import('./pages/Booking'))
const Settings = lazy(() => import('./pages/Settings'))
const Notifications = lazy(() => import('./pages/Notifications'))
const ToS = lazy(() => import('./pages/ToS'))
const About = lazy(() => import('./pages/About'))
const ChangePassword = lazy(() => import('./pages/ChangePassword'))
const Contact = lazy(() => import('./pages/Contact'))
const NoMatch = lazy(() => import('./pages/NoMatch'))

const App = () => (
  <Router>
    <div className="App">
      <Suspense fallback={<></>}>
        <Routes>
          <Route path="/sign-in" element={<SignIn />} />
          <Route path="/sign-up" element={<SignUp />} />
          <Route path="/activate" element={<Activate />} />
          <Route path="/forgot-password" element={<ForgotPassword />} />
          <Route path="/reset-password" element={<ResetPassword />} />
          <Route path="/" element={<Home />} />
          <Route path="/properties" element={<Properties />} />
          <Route path="/property" element={<Property />} />
          <Route path="/checkout" element={<Checkout />} />
          <Route path="/bookings" element={<Bookings />} />
          <Route path="/booking" element={<Booking />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="/notifications" element={<Notifications />} />
          <Route path="/change-password" element={<ChangePassword />} />
          <Route path="/about" element={<About />} />
          <Route path="/tos" element={<ToS />} />
          <Route path="/contact" element={<Contact />} />

          <Route path="*" element={<NoMatch />} />
        </Routes>
      </Suspense>
    </div>
  </Router>
  )

export default App

我们使用 React 懒加载来加载每个路由。

我们不会介绍前端的每个页面,但您可以打开源代码查看每个页面(如果您愿意)。

移动应用

Movin' In 提供适用于 Android 和 iOS 的原生移动应用程序。该移动应用程序使用 React Native、Expo 和 TypeScript 构建。与前端一样,移动应用程序允许客户根据地点和时间搜索可用房产,选择房产并进行结账。

如果客户的预订从后端更新,客户将收到推送通知。推送通知使用 Node.js、Expo Server SDK 和 Firebase 构建。

  • ./mobile/assets/ 文件夹包含图像。
  • ./mobile/screens/ 文件夹包含主要的 React Native 屏幕。
  • ./mobile/components/ 文件夹包含 React Native 组件。
  • ./mobile/services/ 包含 Movin' In API 客户端服务。
  • ./mobile/App.tsx 是主 React Native App。

TypeScript 类型定义在

  • ./mobile/types/index.d.ts
  • ./mobile/types/env.d.ts
  • ./mobile/miscellaneous/movininTypes.ts

./mobile/types/ 按如下方式在 ./mobile/tsconfig.json 中加载

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "typeRoots": [
      "./types"
    ]
  }
}

App.tsx 是主 React Native 应用

import 'react-native-gesture-handler'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { RootSiblingParent } from 'react-native-root-siblings'
import { NavigationContainer, NavigationContainerRef } from '@react-navigation/native'
import { StatusBar as ExpoStatusBar } from 'expo-status-bar'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { Provider } from 'react-native-paper'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import DrawerNavigator from './components/DrawerNavigator'
import * as Helper from './common/Helper'
import * as NotificationService from './services/NotificationService'
import * as UserService from './services/UserService'

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
})

// Prevent native splash screen from autohiding before App component declaration
SplashScreen.preventAutoHideAsync()
  .then((result) => console.log(`SplashScreen.preventAutoHideAsync() succeeded: ${result}`))
  .catch(console.warn) // it's good to explicitly catch and inspect any error

const App = () => {
  const [appIsReady, setAppIsReady] = useState(false)
  const responseListener = useRef<Notifications.Subscription>()
  const navigationRef = useRef<NavigationContainerRef<StackParams>>(null)

  useEffect(() => {
    const register = async () => {
      const loggedIn = await UserService.loggedIn()
      if (loggedIn) {
        const currentUser = await UserService.getCurrentUser()
        if (currentUser?._id) {
          await Helper.registerPushToken(currentUser._id)
        } else {
          Helper.error()
        }
      }
    }

    // Register push notifiations token
    register()

    // This listener is fired whenever a user taps on or interacts with a notification (works when app is foregrounded, backgrounded, or killed)
    responseListener.current = Notifications.addNotificationResponseReceivedListener(async (response) => {
      try {
        if (navigationRef.current) {
          const { data } = response.notification.request.content

          if (data.booking) {
            if (data.user && data.notification) {
              await NotificationService.markAsRead(data.user, [data.notification])
            }
            navigationRef.current.navigate('Booking', { id: data.booking })
          } else {
            navigationRef.current.navigate('Notifications', {})
          }
        }
      } catch (err) {
        Helper.error(err, false)
      }
    })

    return () => {
      if (responseListener.current) {
        Notifications.removeNotificationSubscription(responseListener.current)
      }
    }
  }, [])

  setTimeout(() => {
    setAppIsReady(true)
  }, 500)

  const onReady = useCallback(async () => {
    if (appIsReady) {
      // This tells the splash screen to hide immediately! If we call this after
      // `setAppIsReady`, then we may see a blank screen while the app is
      // loading its initial state and rendering its first pixels. So instead,
      // we hide the splash screen once we know the root view has already
      // performed layout.
      await SplashScreen.hideAsync()
    }
  }, [appIsReady])

  if (!appIsReady) {
    return null
  }

  return (
    <SafeAreaProvider>
      <Provider>
        <RootSiblingParent>
          <NavigationContainer ref={navigationRef} onReady={onReady}>
            <ExpoStatusBar style="light" backgroundColor="rgba(0, 0, 0, .9)" />
            <DrawerNavigator />
          </NavigationContainer>
        </RootSiblingParent>
      </Provider>
    </SafeAreaProvider>
  )
}

export default App

我们不会介绍移动应用程序的每个屏幕,但您可以打开源代码查看每个屏幕(如果您愿意)。

后端

后端是一个使用 Node.js、React、MUI 和 TypeScript 构建的 Web 应用程序。通过后端,管理员可以创建和管理代理机构、房产、地点、客户和预订。当从后端创建新的代理机构时,它们会收到一封电子邮件,提示它们创建一个账户,以便访问后端并管理它们的房产和预订。

  • ./backend/src/assets/ 文件夹包含 CSS 和图像。
  • ./backend/src/pages/ 文件夹包含 React 页面。
  • ./backend/src/components/ 文件夹包含 React 组件。
  • ./backend/src/services/ 包含 Movin' In API 客户端服务。
  • ./backend/src/App.tsx 是包含路由的主 React App。
  • ./backend/src/index.tsx 是后端的入口点。

TypeScript 类型定义在包 ./packages/movinin-types 中定义。

后端的 App.tsx 与前端的 App.tsx 类似。

我们不会介绍后端的每个页面,但您可以打开源代码查看每个页面(如果您愿意)。

关注点

我在此项目中构建了许多有用的 React 和 React Native 组件,用于处理图像、文本、下拉菜单、自动完成、单选按钮等。如果您愿意,可以在您的 React 或 React Native 项目中使用这些组件。

使用 React Native 和 Expo 构建移动应用程序非常简单。Expo 使 React Native 的移动开发变得非常简单。

对后端、前端和移动开发使用相同的语言(TypeScript)非常方便。

TypeScript 是一种非常有趣的语言,具有许多优点。通过为 JavaScript 添加静态类型,我们可以避免许多错误,并生成高质量、可扩展、更易读、更易维护且易于调试和测试的代码。

好了!希望您喜欢阅读这篇文章。

历史

© . All rights reserved.