Messaging, Files & Game Invitations

09. 消息资产与游戏房间

统一消息界面承载文本、图片、附件与游戏邀请,服务端分别完成关系校验、持久化、实时推送和房间创建。

6 个核心函数5 个重点文件基于当前工程源码

模块职责

统一消息界面承载文本、图片、附件与游戏邀请,服务端分别完成关系校验、持久化、实时推送和房间创建。

重点文件

调用链

  1. 1用户创建消息
  2. 2按类型选择 UDP/TCP 通道
  3. 3服务端校验关系或群成员
  4. 4保存消息资产
  5. 5推送在线成员
  6. 6客户端去重渲染
私聊文本发送

ChatWindow::sendTextMessage

读取输入框内容,调用 ClientLogic 发送私聊消息并更新本地界面。

chatwindow.cpp · L488–L530
原型void ChatWindow::sendTextMessage()

调用时机

用户点击发送或按下快捷键时调用。

返回说明

无返回值。

参数

无显式参数。

执行流程

  1. 读取并规范文本
  2. 校验非空
  3. 调用 requestSendChat
  4. 添加本地消息气泡
  5. 清空输入

工程说明

发送与渲染分离,服务端历史同步再通过消息键去重。

关联接口

查看完整实现
chatwindow.cpp
void ChatWindow::sendTextMessage()
{
    if (!m_logic || !msgEdit) {
        return;
    }

    QString msg = msgEdit->text().trimmed();

    if (msg.isEmpty()) {
        return;
    }

    qint64 now = QDateTime::currentSecsSinceEpoch();
    QString displaySender = "我";

    if (!parseCustomGamePayload(msg).isEmpty()) {
        QString content = normalizeCustomGameInviteContent(msg);

        m_logic->requestSendChat(m_targetId, content);
        appendCustomGameInviteMessage(displaySender, content, true, now);

        msgEdit->clear();
        msgEdit->setFocus();
        return;
    }

    m_logic->requestSendChat(m_targetId, msg);

    appendMessageToUI(displaySender, msg, true, MessageBubbleWidget::Text, now);

    msgEdit->clear();
    msgEdit->setFocus();
}
私聊附件上传

ChatWindow::chooseAndUploadFile

选择本地文件,创建 TcpFileUploader,并在上传成功后发送文件消息通知。

chatwindow.cpp · L532–L644
原型void ChatWindow::chooseAndUploadFile()

调用时机

用户点击私聊文件入口时调用。

返回说明

无返回值。

参数

无显式参数。

执行流程

  1. 选择文件
  2. 校验路径与大小
  3. 创建上传器
  4. 设置 token/session/scene
  5. 监听进度
  6. 完成后发送聊天元数据

工程说明

文件本体和消息通知分别走 TCP 与 UDP。

关联接口

查看完整实现
chatwindow.cpp
void ChatWindow::chooseAndUploadFile()
{
    if (!m_logic) {
        return;
    }

    QString filePath = QFileDialog::getOpenFileName(
        this,
        "选择要发送的文件",
        "",
        "游戏配置/文件 (*.json *.p2pgameconfig *.p2pgamepkg *.p2psave *.png *.jpg *.jpeg *.rar);;所有文件 (*.*)"
    );

    if (filePath.isEmpty()) {
        return;
    }

    FilePreviewDialog previewDlg(filePath, this);

    if (previewDlg.exec() != QDialog::Accepted) {
        return;
    }

    QString finalPath = previewDlg.getFilePath();

    if (finalPath.isEmpty()) {
        return;
    }

    QFileInfo fileInfo(finalPath);

    if (!fileInfo.exists() || !fileInfo.isFile()) {
        QMessageBox::warning(this, "文件不存在", "选择的文件不存在或不是普通文件。");
        return;
    }

    QString fileTypeHint = detectUploadFileTypeHint(finalPath);

    MessageBubbleWidget::MessageType bubbleType = MessageBubbleWidget::File;

    if (fileTypeHint.isEmpty() &&
        (finalPath.endsWith(".png", Qt::CaseInsensitive) ||
         finalPath.endsWith(".jpg", Qt::CaseInsensitive) ||
         finalPath.endsWith(".jpeg", Qt::CaseInsensitive))) {
        bubbleType = MessageBubbleWidget::Image;
    }

    if (fileTypeHint == "custom_game_config") {
        appendCustomGameConfigMessage("我",
                                      finalPath,
                                      true,
                                      QDateTime::currentSecsSinceEpoch(),
                                      true);
    } else {
        appendMessageToUI("我",
                          bubbleType == MessageBubbleWidget::File ? fileInfo.fileName() : finalPath,
                          true,
                          bubbleType,
                          QDateTime::currentSecsSinceEpoch());
    }

    TcpFileUploader* uploader = new TcpFileUploader(
        FILE_SERVER_HOST,
        FILE_SERVER_PORT,
        m_logic->getCurrentUser(),
        m_logic->getAuthToken(),
        m_targetId,
        finalPath,
        this
    );

    uploader->setSessionId(m_logic->getSessionId());
    uploader->setTransferScene("private");

    if (!fileTypeHint.isEmpty()) {
        uploader->setFileTypeHint(fileTypeHint);
    }

    connect(uploader, &TcpFileUploader::uploadProgress,
            this,
            [=](qint64 sent, qint64 total) {
                if (total > 0) {
                    qDebug() << QString("上传进度: %1%").arg((sent * 100) / total);
                }
            });

    connect(uploader, &TcpFileUploader::uploadFinished,
            this,
            [=](bool success, QString msg) {
                uploader->deleteLater();

                if (!success) {
                    appendMessageToUI("系统",
                                      "【传输失败】 " + msg,
                                      false,
                                      MessageBubbleWidget::Text,
                                      QDateTime::currentSecsSinceEpoch());
                    return;
                }

                qDebug() << "[File] upload finished";
            });

    uploader->startUpload();
}
群聊附件上传

GroupChatWindow::sendGroupFile

上传群文件并在完成后向群组发送带文件元数据的消息。

groupchatwindow.cpp · L1065–L1162
原型void GroupChatWindow::sendGroupFile()

调用时机

用户在群聊中选择文件时调用。

返回说明

无返回值。

参数

无显式参数。

执行流程

  1. 选择文件
  2. 创建群聊上传器
  3. 写入 group_id 与 scene
  4. 显示上传进度
  5. 发送 group_chat 文件消息

工程说明

服务端下载时再次校验群成员权限。

关联接口

查看完整实现
groupchatwindow.cpp
void GroupChatWindow::sendGroupFile()
{
    if (!m_logic) {
        QMessageBox::warning(this, "群文件", "客户端业务对象不可用,无法发送文件。");
        return;
    }

    if (m_logic->getCurrentUser().isEmpty() ||
        m_logic->getAuthToken().isEmpty() ||
        m_logic->getSessionId().isEmpty()) {
        QMessageBox::warning(this,
                             "登录态失效",
                             "当前登录会话已失效,请重新登录后再发送群文件。");
        return;
    }

    QString filePath = QFileDialog::getOpenFileName(
        this,
        "选择要发送到群聊的文件",
        QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation)
    );

    if (filePath.trimmed().isEmpty()) {
        return;
    }

    QFileInfo info(filePath);

    if (!info.exists() || !info.isFile()) {
        QMessageBox::warning(this, "群文件", "请选择一个有效的本地文件。");
        return;
    }

    if (info.size() <= 0) {
        QMessageBox::warning(this, "群文件", "不能发送空文件。");
        return;
    }

    TcpFileUploader* uploader = new TcpFileUploader(
        tcpServerIp(m_logic),
        TCP_FILE_PORT,
        m_logic->getCurrentUser(),
        m_logic->getAuthToken(),
        QString::number(m_groupId),
        filePath,
        this
    );

    uploader->setSessionId(m_logic->getSessionId());
    uploader->setTransferScene("group");

    QString lowerName = info.fileName().toLower();
    if (lowerName.endsWith(".p2pgameconfig") ||
        lowerName.endsWith("_config.json") ||
        lowerName == "room_config.json") {
        uploader->setFileTypeHint("custom_game_config");
    }

    sendFileBtn->setEnabled(false);
    statusLabel->setText(QString("群ID:%1  角色:%2  正在上传群文件:%3")
                             .arg(m_groupId)
                             .arg(m_myRole)
                             .arg(info.fileName()));

    connect(uploader, &TcpFileUploader::uploadProgress,
            this,
            [=](qint64 sent, qint64 total) {
                statusLabel->setText(QString("群ID:%1  角色:%2  群文件上传中:%3 / %4")
                                         .arg(m_groupId)
                                         .arg(m_myRole)
                                         .arg(readableFileSize(sent))
                                         .arg(readableFileSize(total)));
            });

    connect(uploader, &TcpFileUploader::uploadFinished,
            this,
            [=](bool success, const QString& msg) {
                sendFileBtn->setEnabled(true);
                statusLabel->setText(QString("群ID:%1  角色:%2").arg(m_groupId).arg(m_myRole));

                if (!success) {
                    QMessageBox::warning(this, "群文件上传失败", msg);
                    return;
                }

                appendTextMessage("系统",
                                  "群文件已上传,等待服务器广播文件消息。",
                                  false,
                                  QDateTime::currentSecsSinceEpoch());
            });

    uploader->startUpload();
}
群聊消息处理

BusinessHandler::handleGroupChat

认证发送者、校验群成员身份、保存消息并向在线成员广播。

business_handler.cpp · L1420–L1546
原型void BusinessHandler::handleGroupChat(const std::string& json_str, struct sockaddr_in client_addr)

调用时机

dispatchTask 识别 group_chat 时调用。

返回说明

无直接返回。

参数

参数说明
json_str群聊消息 JSON
client_addr发送者地址

执行流程

  1. 解析消息
  2. 统一鉴权
  3. 校验 group_members
  4. 生成/读取 msg_id
  5. 写入 MySQL
  6. 遍历在线成员转发

工程说明

消息唯一标识用于实时消息与历史同步去重。

关联接口

查看完整实现
business_handler.cpp
void BusinessHandler::handleGroupChat(const std::string& json_str,
                                      struct sockaddr_in client_addr)
{
    try {
        json j = json::parse(json_str);

        std::string from;
        if (!checkAuth(j, client_addr, from)) {
            return;
        }

        json resp;
        resp["cmd"] = "group_chat_resp";

        uint64_t groupId = 0;
        if (!getUint64Field(j, "group_id", groupId)) {
            resp["success"] = false;
            resp["msg"] = "group_id 非法或缺失";
            net_server->sendData(resp.dump(), client_addr);
            return;
        }

        if (!j.contains("content") || !j["content"].is_string()) {
            resp["success"] = false;
            resp["msg"] = "缺少 content";
            net_server->sendData(resp.dump(), client_addr);
            return;
        }

        std::string content = j["content"].get<std::string>();
        std::string msgType = getStringField(j, "msg_type", "text");

        if (content.empty()) {
            resp["success"] = false;
            resp["msg"] = "消息不能为空";
            net_server->sendData(resp.dump(), client_addr);
            return;
        }

        if (content.size() > 8192) {
            resp["success"] = false;
            resp["msg"] = "群消息过长";
            net_server->sendData(resp.dump(), client_addr);
            return;
        }

        if (msgType.empty() || msgType.size() > 32) {
            msgType = "text";
        }

        if (!DBManager::getInstance().isGroupMember(groupId, from)) {
            resp["success"] = false;
            resp["msg"] = "你不是该群成员";
            resp["group_id"] = groupId;
            net_server->sendData(resp.dump(), client_addr);
            return;
        }

        int64_t now = nowSeconds();
        std::string msgId = makeGroupMsgId(groupId, from);
        std::string outMsg;

        bool ok = DBManager::getInstance().saveGroupMessage(
            msgId,
            groupId,
            from,
            msgType,
            content,
            now,
            outMsg
        );

        resp["success"] = ok;
        resp["msg"] = outMsg;
        resp["group_id"] = groupId;
        resp["msg_id"] = msgId;

        net_server->sendData(resp.dump(), client_addr);

        if (!ok) {
            std::cout << "⚠️ [群聊失败] [" << from << "] group_id="
                      << groupId << " 原因: " << outMsg << "\n";
            return;
        }

        json push;
        push["cmd"] = "group_msg";
        push["group_id"] = groupId;
        push["sender"] = from;
        push["content"] = content;
        push["msg_type"] = msgType;
        push["msg_id"] = msgId;
        push["created_at"] = now;

        std::vector<std::string> members =
            DBManager::getInstance().getGroupMemberIds(groupId);

        int pushCount = 0;

        for (const std::string& memberId : members) {

            if (memberId == from) {
                continue;
            }

            sockaddr_in target_addr{};

            if (net_server->getUserAddress(memberId, target_addr)) {
                net_server->sendData(push.dump(), target_addr);
                pushCount++;
            }
        }

        std::cout << "💬 [群聊] [" << from << "] -> group_id="
                  << groupId
                  << " type=" << msgType
                  << " msg=" << clipForLog(content)
                  << ",实时推送 " << pushCount << " 个在线成员\n";

    } catch (const std::exception& e) {
        std::cerr << "群聊消息异常: " << e.what() << "\n";
    }
}
游戏房间创建

BusinessHandler::handleCreateGameRoom

验证群成员与 game_id,创建游戏房间并返回可分享的房间信息。

business_handler.cpp · L2149–L2235
原型void BusinessHandler::handleCreateGameRoom(const std::string& json_str, struct sockaddr_in client_addr)

调用时机

群聊中创建游戏房间时调用。

返回说明

无直接返回。

参数

参数说明
json_str创建房间请求
client_addr房主地址

执行流程

  1. 认证房主
  2. 校验群成员
  3. 校验 game_id
  4. 调用 DBManager 创建房间
  5. 返回 room_id 与配置

工程说明

房间创建后由群聊消息承载邀请入口。

关联接口

查看完整实现
business_handler.cpp
void BusinessHandler::handleCreateGameRoom(const std::string& json_str,
                                           struct sockaddr_in client_addr)
{
    try {
        json j = json::parse(json_str);

        std::string from;
        if (!checkAuth(j, client_addr, from)) {
            return;
        }

        json resp;
        resp["cmd"] = "create_game_room_resp";

        uint64_t groupId = 0;
        if (!getUint64Field(j, "group_id", groupId)) {
            resp["success"] = false;
            resp["msg"] = "group_id 非法或缺失";
            net_server->sendData(resp.dump(), client_addr);
            return;
        }

        std::string gameId = getStringField(j, "game_id");
        std::string gameName = getStringField(j, "game_name", gameId);

        if (!isSafeSimpleId(gameId, 64)) {
            resp["success"] = false;
            resp["msg"] = "game_id 非法";
            resp["group_id"] = groupId;
            net_server->sendData(resp.dump(), client_addr);
            return;
        }

        if (gameName.empty() || gameName.size() > 64) {
            gameName = gameId;
        }

        if (!DBManager::getInstance().isGroupMember(groupId, from)) {
            resp["success"] = false;
            resp["msg"] = "你不是该群成员,不能创建游戏房间";
            resp["group_id"] = groupId;
            resp["game_id"] = gameId;
            resp["game_name"] = gameName;
            net_server->sendData(resp.dump(), client_addr);
            return;
        }

        std::string roomId;
        std::string launcherUrl;
        std::string outMsg;

        bool ok = DBManager::getInstance().createGameRoom(
            groupId,
            from,
            gameId,
            gameName,
            roomId,
            launcherUrl,
            outMsg
        );

        resp["success"] = ok;
        resp["msg"] = outMsg;
        resp["group_id"] = groupId;
        resp["game_id"] = gameId;
        resp["game_name"] = gameName;
        resp["room_id"] = roomId;
        resp["launcher_url"] = launcherUrl;

        net_server->sendData(resp.dump(), client_addr);

        if (ok) {
            std::cout << "🎮 [游戏房间] 用户 [" << from << "] 在 group_id="
                      << groupId << " 创建游戏 [" << gameId
                      << "] room_id=" << roomId << "\n";
        } else {
            std::cout << "⚠️ [游戏房间失败] 用户 [" << from << "] 创建失败: "
                      << outMsg << "\n";
        }

    } catch (const std::exception& e) {
        std::cerr << "创建游戏房间异常: " << e.what() << "\n";
    }
}
游戏邀请渲染

GroupChatWindow::appendGameInviteMessage

将房间信息渲染为可交互游戏卡片,并通过 p2plauncher:// 拉起 Launcher。

groupchatwindow.cpp · L2095–L2236
原型void GroupChatWindow::appendGameInviteMessage(const QString& sender, const QString& content, bool isMe, qint64 ts)

调用时机

收到游戏邀请消息或同步历史时调用。

返回说明

无返回值。

参数

参数说明
sender邀请发送者
roomInfo房间与游戏参数

执行流程

  1. 读取房间参数
  2. 创建卡片控件
  3. 生成 Launcher 链接
  4. 绑定点击事件
  5. 插入消息列表

工程说明

卡片只承载入口,组件检测和游戏启动由 Launcher 负责。

关联接口

查看完整实现
groupchatwindow.cpp
void GroupChatWindow::appendGameInviteMessage(const QString& sender,
                                              const QString& content,
                                              bool isMe,
                                              qint64 ts)
{
    QJsonParseError err;
    QJsonDocument doc = QJsonDocument::fromJson(content.toUtf8(), &err);

    if (err.error != QJsonParseError::NoError || !doc.isObject()) {
        appendTextMessage(sender, content, isMe, ts);
        return;
    }

    QJsonObject obj = doc.object();

    if (obj["game_id"].toString() == "custom_game" ||
        obj["type"].toString() == "custom_game_invite" ||
        obj["launcher_url"].toString().startsWith("p2plauncher://custom_game/join", Qt::CaseInsensitive)) {
        appendCustomGameInviteMessage(sender, content, isMe, ts);
        return;
    }

    QString gameName = obj["game_name"].toString("未知游戏");
    QString roomId = obj["room_id"].toString();
    QString launcherUrl = obj["launcher_url"].toString();
    QString desc = obj["description"].toString("点击加入游戏房间。");

    QString timeText;
    if (ts > 0) {
        timeText = QDateTime::fromSecsSinceEpoch(ts).toString("HH:mm:ss");
    }

    QWidget* rowWidget = new QWidget(messageList);
    QVBoxLayout* rowLayout = new QVBoxLayout(rowWidget);
    rowLayout->setContentsMargins(8, 6, 8, 6);

    QLabel* nameLabel = new QLabel(rowWidget);
    nameLabel->setText(isMe ? QString("我  %1").arg(timeText)
                            : QString("%1  %2").arg(sender, timeText));
    nameLabel->setStyleSheet(isMe ? "color: #1677ff; font-weight: bold;"
                                  : "color: #333; font-weight: bold;");

    QWidget* card = new QWidget(rowWidget);
    card->setStyleSheet(
        "background-color: white; border: 1px solid #d0d7de; "
        "border-radius: 8px;"
        );

    QVBoxLayout* cardLayout = new QVBoxLayout(card);
    cardLayout->setContentsMargins(12, 10, 12, 10);
    cardLayout->setSpacing(8);

    QLabel* titleLabel = new QLabel(QString("🎮 %1").arg(gameName), card);
    titleLabel->setStyleSheet("font-size: 16px; font-weight: bold;");

    QLabel* roomLabel = new QLabel(QString("房间 ID:%1").arg(roomId), card);
    roomLabel->setStyleSheet("color: #555;");

    QLabel* descLabel = new QLabel(desc, card);
    descLabel->setWordWrap(true);
    descLabel->setStyleSheet("color: #666;");

    QPushButton* joinBtn = new QPushButton("启动 Launcher 加入房间", card);
    joinBtn->setMinimumHeight(34);
    joinBtn->setStyleSheet(
        "QPushButton { background-color: #1677ff; color: white; border: none; "
        "border-radius: 5px; padding: 6px 12px; font-weight: bold; }"
        "QPushButton:hover { background-color: #4096ff; }"
        );

    cardLayout->addWidget(titleLabel);
    cardLayout->addWidget(roomLabel);
    cardLayout->addWidget(descLabel);
    cardLayout->addWidget(joinBtn);

    rowLayout->addWidget(nameLabel);
    rowLayout->addWidget(card);

    if (isMe) {
        nameLabel->setAlignment(Qt::AlignRight);
    }

    QListWidgetItem* item = new QListWidgetItem(messageList);
    item->setSizeHint(QSize(0, 150));

    messageList->setItemWidget(item, rowWidget);
    messageList->scrollToBottom();

    connect(joinBtn, &QPushButton::clicked, this, [=]() {
        if (launcherUrl.isEmpty()) {
            QMessageBox::warning(this, "游戏房间", "Launcher 链接为空。");
            return;
        }

        QUrl url(launcherUrl);
        QUrlQuery query(url);

        if (m_logic) {
            QString userId = m_logic->getCurrentUser();
            QString token = m_logic->getAuthToken();

            if (userId.isEmpty() || token.isEmpty()) {
                QMessageBox::warning(this,
                                     "登录态缺失",
                                     "当前客户端没有有效 user_id/token,无法安全进入游戏大厅。\n"
                                     "请重新登录后再点击游戏卡片。");
                return;
            }

            query.removeQueryItem("user_id");
            query.removeQueryItem("token");
            query.addQueryItem("user_id", userId);
            query.addQueryItem("token", token);

            qDebug() << "[GameInvite] open launcher"
                     << "baseUrl=" << launcherUrl
                     << "user_id=" << userId
                     << "token_empty=" << token.isEmpty();
        } else {
            qDebug() << "[GameInvite] m_logic is null";
        }

        url.setQuery(query);

        qDebug() << "[GameInvite] final url=" << url.toString();

        bool ok = QDesktopServices::openUrl(url);

        if (!ok) {
            QMessageBox::warning(
                this,
                "无法启动 Launcher",
                "无法打开 p2plauncher:// 链接。\n\n"
                "请确认 P2PLauncher 已安装并注册 URL 协议。"
                );
        }
    });
}