JavaRush /Java Blog /Random-TW /我們正在新增訂閱一組文章的功能。(第 2 部分)-“Java 專案從頭到尾”
Roman Beekeeper
等級 35

我們正在新增訂閱一組文章的功能。(第 2 部分)-“Java 專案從頭到尾”

在 Random-TW 群組發布
大家好!我們繼續完成上週開始的任務。「Java 專案從 A 到 Z」:新增訂閱一組文章的功能。 第 2 - 1 部分

我們實作 JRTB-5

現在我們需要新增一個命令,以便我們可以訂閱 JavaRush 的一組文章。怎麼做?我們將遵循我想出的最簡單的場景。由於我們可以透過群組 ID 進行訪問,因此需要使用者進行轉移。為此,使用者將輸入命令/addGroupSub GROUP_ID,該命令將以兩種方式之一工作:如果只有命令本身:/addGroupSub,則發送所有群組及其 ID 的清單作為回應。然後使用者將能夠選擇他需要的群組ID,並在此命令中建立請求的第二個版本:/addGroupSub GROUP_ID - 然後將有該使用者的該群組的記錄。我認為我們未來可以做得更好。我們的目標是展示開發成果,而不是超酷的使用者體驗(我很慚愧地說,但我不知道俄語中的這個詞是什麼意思)。要正確添加貫穿整個應用程式的功能(在我們的例子中,從電報機器人客戶端到資料庫),您需要從某個端開始。我們將從資料庫端執行此操作。

在資料庫中新增新的遷移

首先要做的是新增新的資料庫遷移以及在JR中保存用戶群組訂閱資料的能力。要記住它應該如何,請返回文章“專案規劃:測量七次”。第二張照片中有資料庫的大概圖表。我們需要添加一個表格來儲存群組資訊:
  • JavaRush 中的群組 ID 也將是我們的 ID。我們信任他們並相信這些 ID 是唯一的;
  • 標題 - 在我們的圖片中,它是名稱 - 團體的非正式名稱;也就是我們在JavaRush網站上看到的;
  • last_article_id - 這是一個有趣的領域。它將儲存該群組中文章的最後一個 ID,機器人已將其發送給其訂閱者。使用此字段,搜尋新文章的機制將起作用。新訂閱者不會收到用戶訂閱之前發布的文章:只會收到訂閱該群組後發布的文章。
我們也會在群組和使用者表之間建立多對多的關係,因為每個使用者可以有多個群組訂閱(一對多),而每個群組訂閱可以有多個使用者(一對多,僅另一方面)。事實證明,這將是我們的多對多。對於有疑問的人,請查看資料庫中的文章。是的,我很快就計劃在 Telegram 頻道上建立一個帖子,在那裡我將整理資料庫中的所有文章。這就是我們的第二次資料庫遷移的樣子。
V00002__created_groupsub_many_to_many.sql:

-- add PRIMARY KEY FOR tg_user
ALTER TABLE tg_user ADD PRIMARY KEY (chat_id);

-- ensure that the tables with these names are removed before creating a new one.
DROP TABLE IF EXISTS group_sub;
DROP TABLE IF EXISTS group_x_user;

CREATE TABLE group_sub (
   id INT,
   title VARCHAR(100),
   last_article_id INT,
   PRIMARY KEY (id)
);

CREATE TABLE group_x_user (
   group_sub_id INT NOT NULL,
   user_id VARCHAR(100) NOT NULL,
   FOREIGN KEY (user_id) REFERENCES tg_user(chat_id),
   FOREIGN KEY (group_sub_id) REFERENCES group_sub(id),
   UNIQUE(user_id, group_sub_id)
);
需要注意的是,首先我更改舊表 - 我向其中添加主鍵。我當時不知何故錯過了這一點,但現在 MySQL 沒有給我為 gorup_x_user 表添加外鍵的機會,作為遷移的一部分,我更新了資料庫。請注意一個重要的方面。更改資料庫應該完全按照這種方式完成 - 所需的一切都在新遷移中,而不是透過更新已發布的遷移來完成。是的,在我們的例子中什麼也不會發生,因為這是一個測試項目,我們知道它只部署在一個地方,但這將是錯誤的方法。但我們希望一切都正確。接下來是在建立表格之前刪除表。為什麼是這樣?因此,如果資料庫中偶然存在具有此類名稱的表,遷移就不會失敗並且會完全按照預期進行。然後我們添加兩個表。一切都如我們所願。現在我們需要啟動我們的應用程式。如果一切都開始並且沒有中斷,那麼遷移就會被記錄下來。為了仔細檢查這一點,我們進入資料庫以確保:a)此類表已出現;b) 飛行路線技術表中有一個新條目。這樣就完成了遷移工作,讓我們繼續討論儲存庫。

新增儲存庫層

感謝 Spring Boot Data,這裡的一切都非常簡單:我們需要添加 GroupSub 實體,稍微更新 TelegramUser 並添加一個幾乎空的 GroupSubRepository:我們將 GroupSub 實體添加到與 TelegramUser 相同的包中:
package com.github.javarushcommunity.jrtb.repository.entity;

import lombok.Data;
import lombok.EqualsAndHashCode;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

import static java.util.Objects.isNull;

@Data
@Entity
@Table(name = "group_sub")
@EqualsAndHashCode
public class GroupSub {

   @Id
   private Integer id;

   @Column(name = "title")
   private String title;

   @Column(name = "last_article_id")
   private Integer lastArticleId;

   @ManyToMany(fetch = FetchType.EAGER)
   @JoinTable(
           name = "group_x_user",
           joinColumns = @JoinColumn(name = "group_sub_id"),
           inverseJoinColumns = @JoinColumn(name = "user_id")
   )
   private List<TelegramUser> users;

   public void addUser(TelegramUser telegramUser) {
       if (isNull(users)) {
           users = new ArrayList<>();
       }
       users.add(telegramUser);
   }
}
值得注意的一件事是,我們有一個額外的用戶字段,其中包含訂閱該群組的所有用戶的集合。兩個註釋 - ManyToMany 和 JoinTable - 正是我們所需要的。需要為 TelegramUser 新增相同的欄位:
@ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
private List<GroupSub> groupSubs;
此欄位使用 GroupSub 實體中編寫的聯結。事實上,我們的 GroupSub 儲存庫類別是GroupSubRepository
package com.github.javarushcommunity.jrtb.repository;

import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
* {@link Repository} for {@link GroupSub} entity.
*/
@Repository
public interface GroupSubRepository extends JpaRepository<GroupSub, Integer> {
}
在這個階段,我們不需要額外的方法:那些在 JpaRepository 祖先中實現的方法對我們來說就足夠了。讓我們在 TelegramUserRepositoryIT 中編寫一個測試來檢查我們的多對多是否有效。測試的想法是,我們將透過 SQL 腳本向資料庫中的每個用戶添加 5 組訂閱,透過用戶 ID 獲取該用戶,並檢查我們是否收到了完全相同的這些群組以及完全相同的值。怎麼做?您可以將計數器嵌入到資料中,然後我們可以對其進行檢查。這是 FiveGroupSubsForUser.sql 腳本:
INSERT INTO tg_user VALUES (1, 1);

INSERT INTO group_sub VALUES
(1, 'g1', 1),
(2, 'g2', 2),
(3, 'g3', 3),
(4, 'g4', 4),
(5, 'g5', 5);

INSERT INTO group_x_user VALUES
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 1);
以及測試本身:
@Sql(scripts = {"/sql/clearDbs.sql", "/sql/fiveGroupSubsForUser.sql"})
@Test
public void shouldProperlyGetAllGroupSubsForUser() {
   //when
   Optional<TelegramUser> userFromDB = telegramUserRepository.findById("1");

   //then
   Assertions.assertTrue(userFromDB.isPresent());
   List<GroupSub> groupSubs = userFromDB.get().getGroupSubs();
   for (int i = 0; i < groupSubs.size(); i++) {
       Assertions.assertEquals(String.format("g%s", (i + 1)), groupSubs.get(i).getTitle());
       Assertions.assertEquals(i + 1, groupSubs.get(i).getId());
       Assertions.assertEquals(i + 1, groupSubs.get(i).getLastArticleId());
   }
}
現在讓我們為 GroupSub 實體添加相同意義的測試。為此,我們在與groupSubRepositoryIT相同的套件中建立一個測試類別groupSubRepositoryIT
package com.github.javarushcommunity.jrtb.repository;

import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;

import java.util.List;
import java.util.Optional;

import static org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace.NONE;

/**
* Integration-level testing for {@link GroupSubRepository}.
*/
@ActiveProfiles("test")
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
public class GroupSubRepositoryIT {

   @Autowired
   private GroupSubRepository groupSubRepository;

   @Sql(scripts = {"/sql/clearDbs.sql", "/sql/fiveUsersForGroupSub.sql"})
   @Test
   public void shouldProperlyGetAllUsersForGroupSub() {
       //when
       Optional<GroupSub> groupSubFromDB = groupSubRepository.findById(1);

       //then
       Assertions.assertTrue(groupSubFromDB.isPresent());
       Assertions.assertEquals(1, groupSubFromDB.get().getId());
       List<TelegramUser> users = groupSubFromDB.get().getUsers();
       for(int i=0; i<users.size(); i++) {
           Assertions.assertEquals(String.valueOf(i + 1), users.get(i).getChatId());
           Assertions.assertTrue(users.get(i).isActive());
       }
   }
}
以及缺少的 FiveUsersForGroupSub.sql 腳本:
INSERT INTO tg_user VALUES
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 1);

INSERT INTO group_sub VALUES (1, 'g1', 1);

INSERT INTO group_x_user VALUES
(1, 1),
(1, 2),
(1, 3),
(1, 4),
(1, 5);
至此,可以認為儲存庫的部分工作完成了。現在讓我們來寫一個服務層。

我們編寫GroupSubService

在此階段,要處理訂閱群組,我們只需要能夠保存它們,所以沒問題:我們在包含其他服務的套件中建立 GroupSubService 服務及其 GroupSubServiceImpl 的實作 - 服務:
package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;

/**
* Service for manipulating with {@link GroupSub}.
*/
public interface GroupSubService {

   GroupSub save(String chatId, GroupDiscussionInfo groupDiscussionInfo);
}
及其實作:
package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.GroupSubRepository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.ws.rs.NotFoundException;
import java.util.Optional;

@Service
public class GroupSubServiceImpl implements GroupSubService {

   private final GroupSubRepository groupSubRepository;
   private final TelegramUserService telegramUserService;

   @Autowired
   public GroupSubServiceImpl(GroupSubRepository groupSubRepository, TelegramUserService telegramUserService) {
       this.groupSubRepository = groupSubRepository;
       this.telegramUserService = telegramUserService;
   }

   @Override
   public GroupSub save(String chatId, GroupDiscussionInfo groupDiscussionInfo) {
       TelegramUser telegramUser = telegramUserService.findByChatId(chatId).orElseThrow(NotFoundException::new);
       //TODO add exception handling
       GroupSub groupSub;
       Optional<GroupSub> groupSubFromDB = groupSubRepository.findById(groupDiscussionInfo.getId());
       if(groupSubFromDB.isPresent()) {
           groupSub = groupSubFromDB.get();
           Optional<TelegramUser> first = groupSub.getUsers().stream()
                   .filter(it -> it.getChatId().equalsIgnoreCase(chatId))
                   .findFirst();
           if(first.isEmpty()) {
               groupSub.addUser(telegramUser);
           }
       } else {
           groupSub = new GroupSub();
           groupSub.addUser(telegramUser);
           groupSub.setId(groupDiscussionInfo.getId());
           groupSub.setTitle(groupDiscussionInfo.getTitle());
       }
       return groupSubRepository.save(groupSub);
   }
}
為了讓 Spring Data 正常運作並建立多對多記錄,我們需要從資料庫中取得我們正在建立的訂閱群組的使用者並將其新增至 GroupSub 物件。因此,當我們傳輸此訂閱進行儲存時,也會透過 group_x_user 表建立一個連線。可能存在這樣的情況:這樣的訂閱群組已經創建,您只需要在其中添加另一個用戶。為此,我們首先從資料庫中取得群組 ID,如果有記錄,我們將使用它,如果沒有,我們將建立新記錄。值得注意的是,為了與 TelegramUser 合作,我們使用 TelegramUserService 遵循最後一個 SOLID 原則。目前,如果我們沒有透過 ID 找到記錄,我只會拋出異常。現在還沒有以任何方式對其進行處理:我們將在最後、MVP 之前執行此操作。讓我們為GroupSubServiceTest類別寫兩個單元測試。我們需要哪些?我想確保在 GroupSubRepository 中呼叫 save 方法,並且將具有一個使用者的實體傳遞給 GroupSub - 該實體將使用提供的 ID 將 TelegramUserService 傳回給我們。第二個選項,當資料庫中已經存在具有相同ID的群組並且該群組已經有一個使用者時,您需要檢查是否將另一個使用者新增至該群組並儲存該物件。這是實現:
package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.GroupSubRepository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.Optional;

@DisplayName("Unit-level testing for GroupSubService")
public class GroupSubServiceTest {

   private GroupSubService groupSubService;
   private GroupSubRepository groupSubRepository;
   private TelegramUser newUser;

   private final static String CHAT_ID = "1";

   @BeforeEach
   public void init() {
       TelegramUserService telegramUserService = Mockito.mock(TelegramUserService.class);
       groupSubRepository = Mockito.mock(GroupSubRepository.class);
       groupSubService = new GroupSubServiceImpl(groupSubRepository, telegramUserService);

       newUser = new TelegramUser();
       newUser.setActive(true);
       newUser.setChatId(CHAT_ID);

       Mockito.when(telegramUserService.findByChatId(CHAT_ID)).thenReturn(Optional.of(newUser));
   }

   @Test
   public void shouldProperlySaveGroup() {
       //given

       GroupDiscussionInfo groupDiscussionInfo = new GroupDiscussionInfo();
       groupDiscussionInfo.setId(1);
       groupDiscussionInfo.setTitle("g1");

       GroupSub expectedGroupSub = new GroupSub();
       expectedGroupSub.setId(groupDiscussionInfo.getId());
       expectedGroupSub.setTitle(groupDiscussionInfo.getTitle());
       expectedGroupSub.addUser(newUser);

       //when
       groupSubService.save(CHAT_ID, groupDiscussionInfo);

       //then
       Mockito.verify(groupSubRepository).save(expectedGroupSub);
   }

   @Test
   public void shouldProperlyAddUserToExistingGroup() {
       //given
       TelegramUser oldTelegramUser = new TelegramUser();
       oldTelegramUser.setChatId("2");
       oldTelegramUser.setActive(true);

       GroupDiscussionInfo groupDiscussionInfo = new GroupDiscussionInfo();
       groupDiscussionInfo.setId(1);
       groupDiscussionInfo.setTitle("g1");

       GroupSub groupFromDB = new GroupSub();
       groupFromDB.setId(groupDiscussionInfo.getId());
       groupFromDB.setTitle(groupDiscussionInfo.getTitle());
       groupFromDB.addUser(oldTelegramUser);

       Mockito.when(groupSubRepository.findById(groupDiscussionInfo.getId())).thenReturn(Optional.of(groupFromDB));

       GroupSub expectedGroupSub = new GroupSub();
       expectedGroupSub.setId(groupDiscussionInfo.getId());
       expectedGroupSub.setTitle(groupDiscussionInfo.getTitle());
       expectedGroupSub.addUser(oldTelegramUser);
       expectedGroupSub.addUser(newUser);

       //when
       groupSubService.save(CHAT_ID, groupDiscussionInfo);

       //then
       Mockito.verify(groupSubRepository).findById(groupDiscussionInfo.getId());
       Mockito.verify(groupSubRepository).save(expectedGroupSub);
   }

}
我還添加了帶有 BeforeEach 註釋的init()方法。透過這種方式,您通常會建立在每個測試運行之前執行的方法,並且您可以將所有測試的通用邏輯放入其中。在我們的例子中,我們需要以相同的方式鎖定 TelegramUserService 對於此類的所有測試,因此將此邏輯轉移到通用方法是有意義的。這裡使用了兩種 mokito 設計:
  • Mockito.when(o1.m1(a1)).thenReturn(o2) - 在其中我們說,當使用參數a1對物件o1 呼叫方法 m1,該方法將傳回物件o2。這幾乎是mockito最重要的功能──強制mock物件準確地回傳我們需要的內容;

  • Mockito.verify(o1).m1(a1) - 驗證使用參數a1在物件o1上呼叫方法m1。當然,可以使用 save 方法返回的對象,但我決定透過顯示另一種可能的方法來使其變得更複雜一些。什麼時候才能有用呢?在模擬類別的方法傳回 void 的情況下。那麼如果沒有 Mockito.verify 將無法運作)))

我們繼續堅持測試需要寫的想法,而且需要寫很多測試。下一階段是與電報機器人團隊合作。

建立指令 /addGroupSub

這裡我們需要執行以下邏輯:如果我們只收到一個命令,沒有任何上下文,我們會幫助使用者並向他提供所有群組及其 ID 的列表,以便他可以將必要的資訊傳遞給機器人。如果使用者向機器人發送帶有其他單字的命令 - 找到具有該 ID 的群組或寫下沒有這樣的群組。讓我們在 ename 中新增一個值 - CommandName:
ADD_GROUP_SUB("/addgroupsub")
讓我們進一步從資料庫轉向電報機器人 -在命令包中 建立AddGroupSubCommand類別:
package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.javarushclient.JavaRushGroupClient;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupRequestArgs;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.service.GroupSubService;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

import java.util.stream.Collectors;

import static com.github.javarushcommunity.jrtb.command.CommandName.ADD_GROUP_SUB;
import static com.github.javarushcommunity.jrtb.command.CommandUtils.getChatId;
import static com.github.javarushcommunity.jrtb.command.CommandUtils.getMessage;
import static java.util.Objects.isNull;
import static org.apache.commons.lang3.StringUtils.SPACE;
import static org.apache.commons.lang3.StringUtils.isNumeric;

/**
* Add Group subscription {@link Command}.
*/
public class AddGroupSubCommand implements Command {

   private final SendBotMessageService sendBotMessageService;
   private final JavaRushGroupClient javaRushGroupClient;
   private final GroupSubService groupSubService;

   public AddGroupSubCommand(SendBotMessageService sendBotMessageService, JavaRushGroupClient javaRushGroupClient,
                             GroupSubService groupSubService) {
       this.sendBotMessageService = sendBotMessageService;
       this.javaRushGroupClient = javaRushGroupClient;
       this.groupSubService = groupSubService;
   }

   @Override
   public void execute(Update update) {
       if (getMessage(update).equalsIgnoreCase(ADD_GROUP_SUB.getCommandName())) {
           sendGroupIdList(getChatId(update));
           return;
       }
       String groupId = getMessage(update).split(SPACE)[1];
       String chatId = getChatId(update);
       if (isNumeric(groupId)) {
           GroupDiscussionInfo groupById = javaRushGroupClient.getGroupById(Integer.parseInt(groupId));
           if (isNull(groupById.getId())) {
               sendGroupNotFound(chatId, groupId);
           }
           GroupSub savedGroupSub = groupSubService.save(chatId, groupById);
           sendBotMessageService.sendMessage(chatId, "Подписал на группу " + savedGroupSub.getTitle());
       } else {
           sendGroupNotFound(chatId, groupId);
       }
   }

   private void sendGroupNotFound(String chatId, String groupId) {
       String groupNotFoundMessage = "Нет группы с ID = \"%s\"";
       sendBotMessageService.sendMessage(chatId, String.format(groupNotFoundMessage, groupId));
   }

   private void sendGroupIdList(String chatId) {
       String groupIds = javaRushGroupClient.getGroupList(GroupRequestArgs.builder().build()).stream()
               .map(group -> String.format("%s - %s \n", group.getTitle(), group.getId()))
               .collect(Collectors.joining());

       String message = "Whatбы подписаться на группу - передай комадну вместе с ID группы. \n" +
               "Например: /addGroupSub 16. \n\n" +
               "я подготовил список всех групп - выберай Howую хочешь :) \n\n" +
               "Name группы - ID группы \n\n" +
               "%s";

       sendBotMessageService.sendMessage(chatId, String.format(message, groupIds));
   }
}
這個類別使用apache-commons 庫中的 isNumeric方法,所以讓我們將它加入我們的記憶體:
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
  <version>${apache.commons.version}</version>
</dependency>
在屬性塊中:
<apache.commons.version>3.11</apache.commons.version>
所有這些邏輯都在課堂上。仔細閱讀。如果您有任何問題/建議,請將其寫在評論中。之後,我們需要將命令新增至命令映射中的 CommandContainer:
.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, javaRushGroupClient, groupSubService))
以及這支球隊的一切。我想以某種方式測試這個功能,但到目前為止我只能在資料庫中真正查看它。在第三部分中,我將新增 JRTB-6 中的更改,以便我們可以查看使用者訂閱的群組清單。現在最好檢查一下這一切。為此,我們將執行 Telegram 中的所有操作並檢查資料庫。既然我們已經編寫了測試,那麼一切都應該沒問題。文章已經比較長了,後面我們會寫一個AddGroupSubCommand的測試,並在程式碼中加入TODO以免忘記。

結論

在本文中,我們研究了在整個應用程式中添加功能的工作,從資料庫開始,到與使用機器人的客戶合作結束。通常此類任務有助於理解專案並理解其本質。了解它是如何工作的。這些天的話題並不容易,所以不要害羞:在評論中寫下你的問題,我會盡力回答。你喜歡這個項目嗎?在 Github 上給它一顆星:這樣就可以清楚地看出他們對這個專案感興趣,我會很高興。正如人們所說,當他的作品受到讚賞時,大師總是感到高興。該程式碼將包含 STEP_6 的所有三個部分,並將在本文之前提供。如何了解它?這很簡單 - 加入telegram 頻道,我在其中發布有關 telegram 機器人的文章的所有資訊。謝謝閱讀!第 3 部分已經在這裡

此系列所有資料的清單位於本文開頭。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION