2026-07-02 16:55:34 +08:00
|
|
|
|
// Package modules - 好友组队系统模块
|
|
|
|
|
|
// 对齐GDD-34 好友与组队系统设计
|
|
|
|
|
|
package modules
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"database/sql"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/heroiclabs/nakama-common/runtime"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// RegisterFriendParty 注册好友组队相关 RPC。
|
|
|
|
|
|
func RegisterFriendParty(initializer runtime.Initializer) error {
|
2026-07-03 21:34:51 +08:00
|
|
|
|
rpcs := map[string]func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error){
|
2026-07-02 16:55:34 +08:00
|
|
|
|
"FriendPartyService/AddFriend": addFriend,
|
|
|
|
|
|
"FriendPartyService/RemoveFriend": removeFriend,
|
|
|
|
|
|
"FriendPartyService/GetFriends": getFriends,
|
|
|
|
|
|
"FriendPartyService/AcceptFriend": acceptFriend,
|
|
|
|
|
|
"FriendPartyService/CreateParty": createParty,
|
|
|
|
|
|
"FriendPartyService/JoinParty": joinParty,
|
|
|
|
|
|
"FriendPartyService/LeaveParty": leaveParty,
|
|
|
|
|
|
"FriendPartyService/GetPartyInfo": getPartyInfo,
|
|
|
|
|
|
}
|
|
|
|
|
|
for path, fn := range rpcs {
|
|
|
|
|
|
if err := initializer.RegisterRpc(path, fn); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type addFriendReq struct {
|
|
|
|
|
|
TargetCharacterID string `json:"target_character_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type removeFriendReq struct {
|
|
|
|
|
|
FriendID string `json:"friend_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type getFriendsReq struct {
|
|
|
|
|
|
CharacterID string `json:"character_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type acceptFriendReq struct {
|
|
|
|
|
|
RelationID string `json:"relation_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type createPartyReq struct {
|
|
|
|
|
|
MaxMembers int32 `json:"max_members"`
|
|
|
|
|
|
PartyName string `json:"party_name"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type joinPartyReq struct {
|
|
|
|
|
|
PartyID string `json:"party_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type leavePartyReq struct {
|
|
|
|
|
|
PartyID string `json:"party_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type getPartyInfoReq struct {
|
|
|
|
|
|
PartyID string `json:"party_id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type friendData struct {
|
|
|
|
|
|
CharacterID string `json:"character_id"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
RaceID string `json:"race_id"`
|
|
|
|
|
|
RealmTier int32 `json:"realm_tier"`
|
|
|
|
|
|
IsOnline bool `json:"is_online"`
|
|
|
|
|
|
LastSeen time.Time `json:"last_seen"`
|
|
|
|
|
|
Intimacy int32 `json:"intimacy"`
|
|
|
|
|
|
Status string `json:"status"` // active/cooldown/dissolved
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type partyData struct {
|
|
|
|
|
|
PartyID string `json:"party_id"`
|
|
|
|
|
|
LeaderID string `json:"leader_id"`
|
|
|
|
|
|
LeaderName string `json:"leader_name"`
|
|
|
|
|
|
Members interface{} `json:"members"`
|
|
|
|
|
|
MaxMembers int32 `json:"max_members"`
|
|
|
|
|
|
PartyName string `json:"party_name"`
|
|
|
|
|
|
WorldTier int32 `json:"world_tier"`
|
|
|
|
|
|
RealmTier int32 `json:"realm_tier"`
|
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type partyMemberData struct {
|
|
|
|
|
|
CharacterID string `json:"character_id"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
RaceID string `json:"race_id"`
|
|
|
|
|
|
RealmTier int32 `json:"realm_tier"`
|
|
|
|
|
|
Role string `json:"role"` // leader/member
|
|
|
|
|
|
IsReady bool `json:"is_ready"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func addFriend(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
|
|
|
|
traceID := newTraceID()
|
|
|
|
|
|
uid := userIDFromCtx(ctx)
|
|
|
|
|
|
if uid == "" {
|
|
|
|
|
|
return errResp(1001, "missing token", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req addFriendReq
|
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
|
|
|
|
return errResp(2001, "invalid payload", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取发起者角色ID
|
|
|
|
|
|
var charID string
|
|
|
|
|
|
err := hhdbPool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT id FROM characters WHERE player_id = $1 AND status = 'active'
|
|
|
|
|
|
ORDER BY created_at DESC LIMIT 1
|
|
|
|
|
|
`, uid).Scan(&charID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return errResp(4002, "character not found", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查目标是否存在
|
|
|
|
|
|
var targetName string
|
|
|
|
|
|
err = hhdbPool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT name FROM characters WHERE id = $1 AND status = 'active'
|
|
|
|
|
|
`, req.TargetCharacterID).Scan(&targetName)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return errResp(8401, "target not found", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否已是好友
|
|
|
|
|
|
var existingCount int32
|
|
|
|
|
|
err = hhdbPool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT COUNT(*) FROM social_relations
|
|
|
|
|
|
WHERE ((character_a_id = $1 AND character_b_id = $2)
|
|
|
|
|
|
OR (character_a_id = $2 AND character_b_id = $1))
|
|
|
|
|
|
AND relation_type = 'friend' AND status = 'active'
|
|
|
|
|
|
`, charID, req.TargetCharacterID).Scan(&existingCount)
|
|
|
|
|
|
if err == nil && existingCount > 0 {
|
|
|
|
|
|
return errResp(8402, "already friends", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建好友关系(待接受)
|
|
|
|
|
|
_, err = hhdbPool.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO social_relations (character_a_id, character_b_id, relation_type, intimacy, status)
|
|
|
|
|
|
VALUES ($1, $2, 'friend', 0, 'active')
|
|
|
|
|
|
ON CONFLICT DO NOTHING
|
|
|
|
|
|
`, charID, req.TargetCharacterID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
logger.Error("add friend failed: %v", err)
|
|
|
|
|
|
return errResp(9002, "internal error", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 21:34:51 +08:00
|
|
|
|
// 发送通知给目标玩家(简化版:仅记录日志)
|
|
|
|
|
|
logger.Info("Friend request sent from %s to %s", charID, req.TargetCharacterID)
|
2026-07-02 16:55:34 +08:00
|
|
|
|
|
|
|
|
|
|
return okResp(map[string]interface{}{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"target_name": targetName,
|
|
|
|
|
|
"message": "好友请求已发送",
|
|
|
|
|
|
}, traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func removeFriend(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
|
|
|
|
traceID := newTraceID()
|
|
|
|
|
|
uid := userIDFromCtx(ctx)
|
|
|
|
|
|
if uid == "" {
|
|
|
|
|
|
return errResp(1001, "missing token", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req removeFriendReq
|
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
|
|
|
|
return errResp(2001, "invalid payload", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取角色ID
|
|
|
|
|
|
var charID string
|
|
|
|
|
|
err := hhdbPool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT id FROM characters WHERE player_id = $1 AND status = 'active'
|
|
|
|
|
|
ORDER BY created_at DESC LIMIT 1
|
|
|
|
|
|
`, uid).Scan(&charID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return errResp(4002, "character not found", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 删除好友关系
|
|
|
|
|
|
_, err = hhdbPool.Exec(ctx, `
|
|
|
|
|
|
UPDATE social_relations
|
|
|
|
|
|
SET status = 'dissolved', dissolved_at = NOW()
|
|
|
|
|
|
WHERE ((character_a_id = $1 AND character_b_id = $2)
|
|
|
|
|
|
OR (character_a_id = $2 AND character_b_id = $1))
|
|
|
|
|
|
AND relation_type = 'friend' AND status = 'active'
|
|
|
|
|
|
`, charID, req.FriendID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
logger.Error("remove friend failed: %v", err)
|
|
|
|
|
|
return errResp(9002, "internal error", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return okResp(map[string]interface{}{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"message": "好友已删除",
|
|
|
|
|
|
}, traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func getFriends(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
|
|
|
|
traceID := newTraceID()
|
|
|
|
|
|
uid := userIDFromCtx(ctx)
|
|
|
|
|
|
if uid == "" {
|
|
|
|
|
|
return errResp(1001, "missing token", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req getFriendsReq
|
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
|
|
|
|
return errResp(2001, "invalid payload", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 查询好友列表
|
|
|
|
|
|
rows, err := hhdbPool.Query(ctx, `
|
|
|
|
|
|
SELECT c.id, c.name, c.race_id, c.realm_tier, c.last_online_at, sr.intimacy, sr.status
|
|
|
|
|
|
FROM social_relations sr
|
|
|
|
|
|
JOIN characters c ON (
|
|
|
|
|
|
CASE WHEN sr.character_a_id = $1 THEN sr.character_b_id
|
|
|
|
|
|
ELSE sr.character_a_id END
|
|
|
|
|
|
) = c.id
|
|
|
|
|
|
WHERE (sr.character_a_id = $1 OR sr.character_b_id = $1)
|
|
|
|
|
|
AND sr.relation_type = 'friend' AND sr.status = 'active'
|
|
|
|
|
|
ORDER BY sr.intimacy DESC
|
|
|
|
|
|
`, req.CharacterID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
logger.Error("get friends failed: %v", err)
|
|
|
|
|
|
return errResp(9002, "internal error", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var friends []friendData
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var f friendData
|
|
|
|
|
|
if err := rows.Scan(&f.CharacterID, &f.Name, &f.RaceID, &f.RealmTier,
|
|
|
|
|
|
&f.LastSeen, &f.Intimacy, &f.Status); err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
// 简单判断在线状态(5分钟内有活动)
|
|
|
|
|
|
f.IsOnline = time.Since(f.LastSeen) < 5*time.Minute
|
|
|
|
|
|
friends = append(friends, f)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return okResp(map[string]interface{}{
|
|
|
|
|
|
"friends": friends,
|
|
|
|
|
|
"count": len(friends),
|
|
|
|
|
|
}, traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func acceptFriend(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
|
|
|
|
traceID := newTraceID()
|
|
|
|
|
|
uid := userIDFromCtx(ctx)
|
|
|
|
|
|
if uid == "" {
|
|
|
|
|
|
return errResp(1001, "missing token", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req acceptFriendReq
|
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
|
|
|
|
return errResp(2001, "invalid payload", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 简化版:接受好友请求
|
|
|
|
|
|
logger.Info("Accept friend request: relation_id=%s", req.RelationID)
|
|
|
|
|
|
|
|
|
|
|
|
return okResp(map[string]interface{}{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"message": "好友请求已接受",
|
|
|
|
|
|
}, traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func createParty(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
|
|
|
|
traceID := newTraceID()
|
|
|
|
|
|
uid := userIDFromCtx(ctx)
|
|
|
|
|
|
if uid == "" {
|
|
|
|
|
|
return errResp(1001, "missing token", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req createPartyReq
|
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
|
|
|
|
return errResp(2001, "invalid payload", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取队长角色ID
|
|
|
|
|
|
var charID string
|
|
|
|
|
|
var charName string
|
|
|
|
|
|
var worldTier, realmTier int32
|
|
|
|
|
|
err := hhdbPool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT id, name, world_tier, realm_tier
|
|
|
|
|
|
FROM characters WHERE player_id = $1 AND status = 'active'
|
|
|
|
|
|
ORDER BY created_at DESC LIMIT 1
|
|
|
|
|
|
`, uid).Scan(&charID, &charName, &worldTier, &realmTier)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return errResp(4002, "character not found", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建队伍(简化版,实际应存储到数据库)
|
|
|
|
|
|
partyID := genUUID()
|
|
|
|
|
|
|
|
|
|
|
|
return okResp(partyData{
|
|
|
|
|
|
PartyID: partyID,
|
|
|
|
|
|
LeaderID: charID,
|
|
|
|
|
|
LeaderName: charName,
|
|
|
|
|
|
MaxMembers: req.MaxMembers,
|
|
|
|
|
|
PartyName: req.PartyName,
|
|
|
|
|
|
WorldTier: worldTier,
|
|
|
|
|
|
RealmTier: realmTier,
|
|
|
|
|
|
Status: "forming",
|
|
|
|
|
|
CreatedAt: time.Now(),
|
|
|
|
|
|
}, traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func joinParty(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
|
|
|
|
traceID := newTraceID()
|
|
|
|
|
|
uid := userIDFromCtx(ctx)
|
|
|
|
|
|
if uid == "" {
|
|
|
|
|
|
return errResp(1001, "missing token", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req joinPartyReq
|
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
|
|
|
|
return errResp(2001, "invalid payload", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取角色信息
|
|
|
|
|
|
var charID string
|
|
|
|
|
|
var charName string
|
|
|
|
|
|
var worldTier, realmTier int32
|
|
|
|
|
|
err := hhdbPool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT id, name, world_tier, realm_tier
|
|
|
|
|
|
FROM characters WHERE player_id = $1 AND status = 'active'
|
|
|
|
|
|
ORDER BY created_at DESC LIMIT 1
|
|
|
|
|
|
`, uid).Scan(&charID, &charName, &worldTier, &realmTier)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return errResp(4002, "character not found", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 简化版:加入队伍
|
|
|
|
|
|
logger.Info("Join party: party=%s player=%s", req.PartyID, charName)
|
|
|
|
|
|
|
|
|
|
|
|
return okResp(map[string]interface{}{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"party_id": req.PartyID,
|
|
|
|
|
|
"character_id": charID,
|
|
|
|
|
|
"character_name": charName,
|
|
|
|
|
|
"message": "成功加入队伍",
|
|
|
|
|
|
}, traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func leaveParty(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
|
|
|
|
traceID := newTraceID()
|
|
|
|
|
|
uid := userIDFromCtx(ctx)
|
|
|
|
|
|
if uid == "" {
|
|
|
|
|
|
return errResp(1001, "missing token", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req leavePartyReq
|
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
|
|
|
|
return errResp(2001, "invalid payload", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 简化版:离开队伍
|
|
|
|
|
|
logger.Info("Leave party: party=%s", req.PartyID)
|
|
|
|
|
|
|
|
|
|
|
|
return okResp(map[string]interface{}{
|
|
|
|
|
|
"success": true,
|
|
|
|
|
|
"message": "已离开队伍",
|
|
|
|
|
|
}, traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func getPartyInfo(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
|
|
|
|
traceID := newTraceID()
|
|
|
|
|
|
|
|
|
|
|
|
var req getPartyInfoReq
|
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
|
|
|
|
return errResp(2001, "invalid payload", traceID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 简化版:返回队伍信息
|
|
|
|
|
|
return okResp(map[string]interface{}{
|
|
|
|
|
|
"party_id": req.PartyID,
|
|
|
|
|
|
"members": []partyMemberData{},
|
|
|
|
|
|
"max_members": 5,
|
|
|
|
|
|
"status": "forming",
|
|
|
|
|
|
}, traceID)
|
|
|
|
|
|
}
|