package core import ( "context" "encoding/gob" "encoding/json" "fmt" "net/http" "reflect" "strings" "time" "github.com/go-redis/redis/v8" "repositories.action2quare.com/ayo/gocommon" "repositories.action2quare.com/ayo/gocommon/logger" "repositories.action2quare.com/ayo/gocommon/wshandler" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" ) type accountID = primitive.ObjectID type groupID = primitive.ObjectID func init() { gob.Register(memberDoc{}) gob.Register(groupDoc{}) gob.Register(Invitation{}) gob.Register(InvitationFail{}) groupTypeContainer()["party"] = reflect.TypeOf(&groupParty{}) } func makeTid(gid groupID, in accountID) string { var out primitive.ObjectID for i := range in { out[12-i-1] = gid[i] ^ in[12-i-1] } return out.Hex() } func midFromTid(gid groupID, in string) accountID { h, _ := primitive.ObjectIDFromHex(in) var out accountID for i := range h { out[12-i-1] = gid[i] ^ h[12-i-1] } return out } type Invitation struct { GroupID groupID `json:"_gid"` TicketID string `json:"_tid"` Inviter bson.M `json:"_inviter"` // memberDoc.Body ExpireAtUTC int64 `json:"_expire_at_utc"` } // 플레이어한테 공유하는 멤버 정보 type memberDoc struct { Body bson.M `json:"_body"` Invite bool `json:"_invite"` InviteExpire int64 `json:"_invite_exp"` } type InvitationFail bson.M type groupDoc struct { Members map[string]any `json:"_members"` InCharge string `json:"_incharge"` Gid string `json:"_gid"` rh *gocommon.RedisonHandler id groupID } func (gd *groupDoc) loadMemberFull(tid string) (bson.M, error) { full, err := gd.rh.JSONGet(gd.strid(), "$._members."+tid) if err != nil { return nil, err } bt := []byte(full.(string)) bt = bt[1 : len(bt)-1] var doc bson.M if err = json.Unmarshal(bt, &doc); err != nil { return nil, err } return doc, nil } func (gd *groupDoc) loadFull() (doc bson.M) { // 새 멤버에 그룹 전체를 알림 full, err := gd.rh.JSONGet(gd.strid(), "$") if err == nil { bt := []byte(full.(string)) bt = bt[1 : len(bt)-1] err = json.Unmarshal(bt, &doc) if err != nil { logger.Println("loadFull err :", err) } } else { logger.Println("loadFull err :", err) } return } func (gd *groupDoc) strid() string { if len(gd.Gid) == 0 { gd.Gid = gd.id.Hex() } return gd.Gid } func (gd *groupDoc) tid(in accountID) string { return makeTid(gd.id, in) } func (gd *groupDoc) mid(tid string) accountID { tidobj, _ := primitive.ObjectIDFromHex(tid) var out primitive.ObjectID for i := range tidobj { out[12-i-1] = gd.id[i] ^ tidobj[12-i-1] } return out } func (gd *groupDoc) addInvite(inviteeDoc bson.M, ttl time.Duration, max int) (*memberDoc, error) { targetmid := inviteeDoc["_mid"].(accountID) targetbody := inviteeDoc["body"].(bson.M) // 초대 가능한 빈 자리가 있나 tids, err := gd.rh.JSONObjKeys(gd.strid(), "$._members") if err != nil { return nil, err } now := time.Now().UTC() createNewDoc := func() *memberDoc { return &memberDoc{ Body: targetbody, Invite: true, InviteExpire: now.Add(ttl).Unix(), } } newtid := gd.tid(targetmid) if len(tids) < max { // 빈자리를 찾았다. newdoc := createNewDoc() _, err := gd.rh.JSONSet(gd.strid(), "$._members."+newtid, newdoc) return newdoc, err } expires, err := gd.rh.JSONGetInt64(gd.strid(), "$._members.._invite_exp") if err != nil { return nil, err } var delpaths []string for i, expire := range expires { if expire < now.Unix() { // 만료된 초대가 있네? 지우자 delpaths = append(delpaths, "$._members."+tids[i]) } } if len(delpaths) == 0 { // 빈자리가 없다 return nil, nil } if err := gd.rh.JSONMDel(gd.strid(), delpaths); err != nil { return nil, err } newdoc := createNewDoc() _, err = gd.rh.JSONSet(gd.strid(), "$._members."+newtid, newdoc) return newdoc, err } func (gd *groupDoc) addMember(mid accountID, doc bson.M) (bson.M, error) { tid := gd.tid(mid) prefix := "$._members." + tid if _, err := gd.rh.JSONMerge(gd.strid(), prefix+"._body", doc, gocommon.RedisonSetOptionXX); err != nil { return nil, err } if err := gd.rh.JSONMDel(gd.strid(), []string{prefix + "._invite", prefix + "._invite_exp"}); err != nil { return nil, err } gd.rh.Persist(gd.rh.Context(), gd.strid()).Result() return gd.loadMemberFull(tid) } func (gd *groupDoc) removeMemberByTid(tid string) error { _, err := gd.rh.JSONDel(gd.strid(), "$._members."+tid) if err != nil { return err } counts, err := gd.rh.JSONObjLen(gd.strid(), "$._members") if err != nil { return err } if len(counts) > 0 && counts[0] == 0 { _, err = gd.rh.Del(gd.rh.Context(), gd.strid()).Result() } return err } func (gd *groupDoc) removeMember(mid accountID) error { return gd.removeMemberByTid(gd.tid(mid)) } func (gd *groupDoc) getMembers() (map[string]any, error) { res, err := gd.rh.JSONGet(gd.strid(), "$._members") if err != nil { return nil, err } var temp []map[string]any err = json.Unmarshal([]byte(res.(string)), &temp) if err != nil { return nil, err } out := make(map[string]any) for k, v := range temp[0] { body := v.(map[string]any)["_body"] out[gd.mid(k).Hex()] = body } return out, nil } type partyConfig struct { InviteExpire int32 `json:"invite_ttl"` // 그룹이 개인에게 보낸 초대장 만료 기한 MaxMember int `json:"max_member"` Name string } type groupParty struct { partyConfig sendUpstreamMessage func(*wshandler.UpstreamMessage) enterRoom func(groupID, accountID) leaveRoom func(groupID, accountID) rh *gocommon.RedisonHandler } func (gp *groupParty) Initialize(sub *subTavern, cfg configDocument) error { rem, _ := json.Marshal(cfg) err := json.Unmarshal(rem, &gp.partyConfig) if err != nil { return err } gp.rh = sub.redison gp.sendUpstreamMessage = func(msg *wshandler.UpstreamMessage) { sub.wsh.SendUpstreamMessage(sub.region, msg) } gp.enterRoom = func(gid groupID, accid accountID) { sub.wsh.EnterRoom(sub.region, gid.Hex(), accid) } gp.leaveRoom = func(gid groupID, accid accountID) { sub.wsh.LeaveRoom(sub.region, gid.Hex(), accid) } sub.apiFuncs.registApiFunction("JoinParty", gp.JoinParty) sub.apiFuncs.registApiFunction("InviteToParty", gp.InviteToParty) sub.apiFuncs.registApiFunction("AcceptPartyInvitation", gp.AcceptPartyInvitation) sub.apiFuncs.registApiFunction("DenyPartyInvitation", gp.DenyPartyInvitation) sub.apiFuncs.registApiFunction("QueryPartyMemberState", gp.QueryPartyMemberState) sub.apiFuncs.registApiFunction("LeaveParty", gp.LeaveParty) sub.apiFuncs.registApiFunction("UpdatePartyMemberDocument", gp.UpdatePartyMemberDocument) sub.apiFuncs.registApiFunction("UpdatePartyDocument", gp.UpdatePartyDocument) sub.apiFuncs.registApiFunction("QueryPartyMembers", gp.QueryPartyMembers) return nil } func (gp *groupParty) RegisterApiFunctions() { } // JoinParty : 그룹에 참가 // - type : 그룹 타입 // - 그룹 타입에 맞는 키(주로 _id) // - member_id : 참가 멤버의 아이디 // - body : 멤버의 속성 bson document func (gp *groupParty) JoinParty(w http.ResponseWriter, r *http.Request) { doc := bson.M{} if err := readBsonDoc(r.Body, &doc); err != nil { logger.Error("JoinParty failed. readBsonDoc returns err :", err) w.WriteHeader(http.StatusBadRequest) return } gid, ok := gocommon.ReadObjectIDFormValue(r.Form, "gid") if !ok { logger.Println("JoinParty failed. gid is missing :", r.Form) w.WriteHeader(http.StatusBadRequest) return } mid, midok := gocommon.ReadObjectIDFormValue(r.Form, "mid") if !midok { logger.Println("JoinParty failed. mid should be exist") w.WriteHeader(http.StatusBadRequest) return } gd, err := gp.find(gid) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if gd == nil { // 그룹이 없다. 실패 w.Write([]byte("{}")) return } // 내 정보 업데이트할 때에도 사용됨 if memdoc, err := gd.addMember(mid, doc); err == nil { // 기존 유저에게 새 유저 알림 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: "#" + gid.Hex(), Body: map[string]any{ gd.tid(mid): memdoc, }, Tag: []string{"MemberDocFull"}, }) gp.enterRoom(gid, mid) // 새 멤버에 그룹 전체를 알림 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: mid.Hex(), Body: gd.loadFull(), Tag: []string{"GroupDocFull"}, }) writeBsonDoc(w, map[string]string{ "gid": gid.Hex(), "tid": gd.tid(mid), }) } else if err != nil { logger.Error("JoinParty failed :", err) w.WriteHeader(http.StatusInternalServerError) } } // InviteToParty : 초대 // - type : 초대 타입 (required) // - from : 초대하는 자 (required) // - to : 초대받는 자 (required) // - timeout : 초대 유지시간(optional. 없으면 config 기본 값) // - (body) : 검색시 노출되는 document func (gp *groupParty) InviteToParty(w http.ResponseWriter, r *http.Request) { gid, ok := gocommon.ReadObjectIDFormValue(r.Form, "gid") if !ok { logger.Println("InviteToParty failed. gid is missing :", r) w.WriteHeader(http.StatusBadRequest) return } mid, ok := gocommon.ReadObjectIDFormValue(r.Form, "mid") if !ok { logger.Println("InviteToParty failed. mid is missing :", r) w.WriteHeader(http.StatusBadRequest) return } var reqdoc struct { Inviter bson.M `bson:"inviter"` Invitee bson.M `bson:"invitee"` } if err := readBsonDoc(r.Body, &reqdoc); err != nil { logger.Println("InviteToParty failed. readBsonDoc returns err :", err) w.WriteHeader(http.StatusInternalServerError) return } targetid, ok := reqdoc.Invitee["_mid"].(accountID) if !ok { logger.Println("InviteToParty failed. invitee mid is missing :", r) w.WriteHeader(http.StatusBadRequest) return } // targetid에 초대한 mid가 들어있다. success, err := gp.rh.SetNX(context.Background(), "inv."+targetid.Hex(), mid.Hex(), time.Duration(gp.InviteExpire)*time.Second).Result() if err != nil { logger.Println("InviteToParty failed. gp.rh.SetNX() return err :", err) w.WriteHeader(http.StatusInternalServerError) return } if !success { // 이미 초대 중이다. // inviter한테 알려줘야 한다. gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: mid.Hex(), Body: reqdoc.Invitee, Tag: []string{"InvitationFail"}, }) return } gd, err := gp.find(gid) if err != nil { logger.Println("InviteToParty failed. gp.find() return err :", err) w.WriteHeader(http.StatusBadRequest) return } if gd == nil { gd, err = gp.createGroup(gid, mid, reqdoc.Inviter) if err != nil { logger.Println("InviteToParty failed. gp.createGroup() return err :", err) w.WriteHeader(http.StatusInternalServerError) return } // 내가 wshandler room에 입장 gp.enterRoom(gid, mid) gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: mid.Hex(), Body: gd, Tag: []string{"GroupDocFull"}, }) } newdoc, err := gd.addInvite(reqdoc.Invitee, time.Duration(gp.InviteExpire+1)*time.Second, gp.MaxMember) if err != nil { logger.Println("InviteToParty failed. gp.addInvite() return err :", err) w.WriteHeader(http.StatusInternalServerError) return } // invitee에게 알림 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: targetid.Hex(), Body: Invitation{ GroupID: gid, TicketID: gd.tid(targetid), Inviter: reqdoc.Inviter, ExpireAtUTC: newdoc.InviteExpire, }, Tag: []string{"Invitation"}, }) w.Write([]byte(gd.strid())) } func (gp *groupParty) AcceptPartyInvitation(w http.ResponseWriter, r *http.Request) { gid, _ := gocommon.ReadObjectIDFormValue(r.Form, "gid") mid, _ := gocommon.ReadObjectIDFormValue(r.Form, "mid") var member bson.M if err := readBsonDoc(r.Body, &member); err != nil { logger.Error("AcceptPartyInvitation failed. readBsonDoc returns err :", err) w.WriteHeader(http.StatusInternalServerError) return } cnt, err := gp.rh.Del(context.Background(), "inv."+mid.Hex()).Result() if err != nil { logger.Error("AcceptPartyInvitation failed. gp.rh.Del returns err :", err) w.WriteHeader(http.StatusInternalServerError) return } if cnt == 0 { // 만료됨 w.Write([]byte("expired")) return } gd := &groupDoc{ id: gid, rh: gp.rh, } memberDoc, err := gd.addMember(mid, member) if err == nil { // 기존 멤버에게 새 멤버를 알림 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: "#" + gid.Hex(), Body: map[string]any{ gd.tid(mid): memberDoc, }, Tag: []string{"MemberDocFull"}, }) gp.enterRoom(gid, mid) // 새 멤버에 그룹 전체를 알림 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: mid.Hex(), Body: gd.loadFull(), Tag: []string{"GroupDocFull"}, }) } else { logger.Println("AcceptPartyInvitation failed. group.AcceptPartyInvitation returns err :", err) w.WriteHeader(http.StatusInternalServerError) } } func (gp *groupParty) DenyPartyInvitation(w http.ResponseWriter, r *http.Request) { gid, _ := gocommon.ReadObjectIDFormValue(r.Form, "gid") mid, _ := gocommon.ReadObjectIDFormValue(r.Form, "mid") gp.rh.Del(context.Background(), "inv."+mid.Hex()).Result() gd := groupDoc{ id: gid, rh: gp.rh, } gd.removeMember(mid) } func (gp *groupParty) QueryPartyMemberState(w http.ResponseWriter, r *http.Request) { mid, ok := gocommon.ReadStringFormValue(r.Form, "mid") if !ok { logger.Println("IsOnline failed. mid is missing :", r.Form) w.WriteHeader(http.StatusBadRequest) return } states, _ := gp.rh.HMGet(gp.rh.Context(), mid, "party_state", "_ts").Result() if states[0] != nil && len(states[0].(string)) > 0 { w.Write([]byte(states[0].(string))) } else if states[1] != nil && len(states[1].(string)) > 0 { w.Write([]byte("connected")) } } // LeaveParty : 그룹에서 나감 or 내보냄 // - type : 그룹 타입 // - 그룹 타입에 맞는 키(주로 _id) // - member_id : 나갈 멤버의 아이디 func (gp *groupParty) LeaveParty(w http.ResponseWriter, r *http.Request) { gid, ok := gocommon.ReadObjectIDFormValue(r.Form, "gid") if !ok { logger.Println("LeaveParty failed. gid is missing :", r.Form) w.WriteHeader(http.StatusBadRequest) return } mid, midok := gocommon.ReadObjectIDFormValue(r.Form, "mid") if !midok { logger.Println("LeaveParty failed. mid is missing") w.WriteHeader(http.StatusBadRequest) return } tid, tidok := gocommon.ReadStringFormValue(r.Form, "tid") gd := groupDoc{ id: gid, rh: gp.rh, } var err error if tidok { if tid != gd.tid(mid) { // mid가 incharge여야 한다. 그래야 tid를 쫓아낼 수 있음 incharge, err := gp.rh.JSONGet(gd.strid(), "$._incharge") if err != nil { logger.Println("LeaveParty failed. gp.rh.JSONGet returns err :", err) w.WriteHeader(http.StatusInternalServerError) return } if !strings.Contains(incharge.(string), gd.tid(mid)) { // incharge가 아니네? logger.Println("LeaveParty failed. mid is not incharge") w.WriteHeader(http.StatusBadRequest) return } mid = midFromTid(gd.id, tid) } err = gd.removeMemberByTid(tid) } else { err = gd.removeMember(mid) } if err != nil { logger.Println("LeaveParty failed. gd.removeMember returns err :", err) w.WriteHeader(http.StatusInternalServerError) return } // mid한테는 빈 GroupDocFull을 보낸다. 그러면 지워짐 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: mid.Hex(), Body: bson.M{"gid": gid}, Tag: []string{"GroupDocFull", gid.Hex()}, }) // gid에는 제거 메시지 보냄 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: "#" + gd.strid(), Body: bson.M{ tid: bson.M{}, }, Tag: []string{"MemberDocFull"}, }) gp.leaveRoom(gid, mid) } func (gp *groupParty) updateMemberDocument(gid groupID, mid accountID, doc bson.M) error { gd := &groupDoc{ id: gid, rh: gp.rh, } prefixPath := fmt.Sprintf("$._members.%s.", gd.tid(mid)) err := gp.rh.JSONMSetRel(gd.strid(), prefixPath, doc) if err != nil { return err } if newstate, ok := doc["_state"]; ok { gp.rh.HSet(gp.rh.Context(), mid.Hex(), "party_state", newstate).Result() } gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: "#" + gid.Hex(), Body: map[string]any{ gd.tid(mid): doc, }, Tag: []string{"MemberDocFragment"}, }) return nil } func (gp *groupParty) UpdatePartyMemberDocument(w http.ResponseWriter, r *http.Request) { mid, ok := gocommon.ReadObjectIDFormValue(r.Form, "mid") if !ok { logger.Println("UpdatePartyMemberDocument failed. member_id is missing") w.WriteHeader(http.StatusBadRequest) return } gid, ok := gocommon.ReadObjectIDFormValue(r.Form, "gid") if !ok { logger.Println("UpdatePartyMemberDocument failed. _id is missing") w.WriteHeader(http.StatusBadRequest) return } var updatedoc bson.M if err := readBsonDoc(r.Body, &updatedoc); err != nil { logger.Error("UpdatePartyMemberDocument failed. body decoding error :", err) w.WriteHeader(http.StatusBadRequest) return } if err := gp.updateMemberDocument(gid, mid, updatedoc); err != nil { logger.Println("UpdatePartyMemberDocument failed :", err) w.WriteHeader(http.StatusInternalServerError) return } } func (gp *groupParty) updatePartyDocument(gid groupID, frag bson.M) error { gd := groupDoc{ id: gid, rh: gp.rh, } if err := gp.rh.JSONMSetRel(gd.strid(), "$.", frag); err != nil { return err } // 업데이트 알림 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: "#" + gid.Hex(), Body: frag, Tag: []string{"GroupDocFragment"}, }) return nil } func (gp *groupParty) UpdatePartyDocument(w http.ResponseWriter, r *http.Request) { gid, ok := gocommon.ReadObjectIDFormValue(r.Form, "gid") if !ok { logger.Println("UpdatePartyDocument failed. gid is missing") w.WriteHeader(http.StatusBadRequest) return } var frag bson.M if err := readBsonDoc(r.Body, &frag); err != nil { logger.Error("UpdatePartyDocument failed. readBsonDoc err :", err) w.WriteHeader(http.StatusBadRequest) return } if err := gp.updatePartyDocument(gid, frag); err != nil { logger.Error("UpdatePartyDocument failed. group.UpdatePartyDocument returns err :", err) w.WriteHeader(http.StatusBadRequest) return } } func (gp *groupParty) QueryPartyMembers(w http.ResponseWriter, r *http.Request) { gid, ok := gocommon.ReadObjectIDFormValue(r.Form, "gid") if !ok { logger.Println("QueryPartyMembers failed. gid is missing") w.WriteHeader(http.StatusBadRequest) return } gd := groupDoc{ id: gid, rh: gp.rh, } members, err := gd.getMembers() if err != nil { logger.Error("QueryPartyMembers failed. group.QueryPartyMembers returns err :", err) w.WriteHeader(http.StatusBadRequest) return } if err := writeBsonDoc(w, members); err != nil { logger.Error("QueryPartyMembers failed. writeBsonDoc return err :", err) w.WriteHeader(http.StatusInternalServerError) return } } func (gp *groupParty) createGroup(newid groupID, charge accountID, chargeDoc bson.M) (*groupDoc, error) { tid := makeTid(newid, charge) gd := &groupDoc{ Members: map[string]any{ tid: &memberDoc{ Body: chargeDoc, Invite: false, InviteExpire: 0, }, }, InCharge: tid, rh: gp.rh, id: newid, } _, err := gp.rh.JSONSet(gd.strid(), "$", gd, gocommon.RedisonSetOptionNX) if err != nil { return nil, err } return gd, nil } func (gp *groupParty) find(id groupID) (*groupDoc, error) { if id.IsZero() { return nil, nil } _, err := gp.rh.JSONObjLen(id.Hex(), "$") if err == redis.Nil { return nil, nil } if err != nil { return nil, err } return &groupDoc{ rh: gp.rh, id: id, }, nil } func (gp *groupParty) memberDisconnected(room string, mid primitive.ObjectID) { gid, err := primitive.ObjectIDFromHex(room) if err != nil { return } gd := &groupDoc{ id: gid, rh: gp.rh, } gd.removeMember(mid) // 퇴장을 알림 gp.sendUpstreamMessage(&wshandler.UpstreamMessage{ Target: "#" + room, Body: bson.M{ gd.tid(mid): bson.M{}, }, Tag: []string{"MemberDocFull"}, }) } func (gp *groupParty) ClientMessageReceived(sender *wshandler.Sender, mt wshandler.WebSocketMessageType, message any) { if mt == wshandler.Disconnected { rooms := message.([]string) for _, roomname := range rooms { gp.memberDisconnected(roomname, sender.Accid) } } else if mt == wshandler.BinaryMessage { commandline := message.([]any) cmd := commandline[0].(string) args := commandline[1:] switch cmd { case "UpdatePartyMemberDocument": gidobj, _ := primitive.ObjectIDFromHex(args[0].(string)) doc := args[1].(map[string]any) gp.updateMemberDocument(gidobj, sender.Accid, doc) case "UpdatePartyDocument": gidobj, _ := primitive.ObjectIDFromHex(args[0].(string)) doc := args[1].(map[string]any) gp.updatePartyDocument(gidobj, doc) } } }