diff --git a/internal/api/router.go b/internal/api/router.go index 0c93af08b..296e3a385 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -386,6 +386,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co rtcGroup.POST("/signal_send_custom_signal", rc.SignalSendCustomSignal) rtcGroup.POST("/get_signal_invitation_records", rc.GetSignalInvitationRecords) rtcGroup.POST("/delete_signal_records", rc.DeleteSignalRecords) + rtcGroup.POST("/get_call_records", rc.GetCallRecords) } // Crypto / E2EE diff --git a/internal/api/rtc.go b/internal/api/rtc.go index 33436435c..831285280 100644 --- a/internal/api/rtc.go +++ b/internal/api/rtc.go @@ -63,3 +63,7 @@ func (o *RtcApi) GetSignalInvitationRecords(c *gin.Context) { func (o *RtcApi) DeleteSignalRecords(c *gin.Context) { a2r.Call(c, rtc.RtcServiceClient.DeleteSignalRecords, o.Client) } + +func (o *RtcApi) GetCallRecords(c *gin.Context) { + a2r.Call(c, rtc.RtcServiceClient.GetCallRecords, o.Client) +} diff --git a/internal/rpc/rtc/server.go b/internal/rpc/rtc/server.go index 40f124f17..fae783b4a 100644 --- a/internal/rpc/rtc/server.go +++ b/internal/rpc/rtc/server.go @@ -61,6 +61,11 @@ func Start(ctx context.Context, cfg *Config, client discovery.SvcDiscoveryRegist return err } + callRecordDB, err := mgo.NewCallRecordMongo(mgocli.GetDB()) + if err != nil { + return err + } + msgConn, err := client.GetConn(ctx, cfg.Share.RpcRegisterName.Msg) if err != nil { return err @@ -91,7 +96,7 @@ func Start(ctx context.Context, cfg *Config, client discovery.SvcDiscoveryRegist s := &rtcServer{ config: cfg, - db: controller.NewRtcDatabase(signalDB), + db: controller.NewRtcDatabase(signalDB, callRecordDB), roomClient: roomClient, msgClient: rpcli.NewMsgClient(msgConn), userClient: rpcli.NewUserClient(userConn), diff --git a/internal/rpc/rtc/signal.go b/internal/rpc/rtc/signal.go index cd794f362..a0cd2108e 100644 --- a/internal/rpc/rtc/signal.go +++ b/internal/rpc/rtc/signal.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/google/uuid" @@ -77,6 +78,10 @@ func (s *rtcServer) SignalMessageAssemble(ctx context.Context, req *rtc.SignalMe r, err := s.handleGetTokenByRoomID(ctx, payload.GetTokenByRoomID) resp.Payload = &rtc.SignalResp_GetTokenByRoomID{GetTokenByRoomID: r} respErr = err + case *rtc.SignalReq_Timeout: + r, err := s.handleTimeout(ctx, payload.Timeout, req.SignalReq) + resp.Payload = &rtc.SignalResp_Timeout{Timeout: r} + respErr = err default: return nil, errs.ErrArgs.WrapMsg("unknown signal payload type") } @@ -403,6 +408,11 @@ func (s *rtcServer) handleAccept(ctx context.Context, req *rtc.SignalAcceptReq, log.ZWarn(ctx, "sendSignalingNotification accept to inviter failed", err, "inviterID", dbInv.InviterUserID) } + // Record the exact moment the callee accepted; used later to split dial vs. call duration. + if err := s.db.SetConnectTime(ctx, dbInv.RoomID, time.Now().UnixMilli()); err != nil { + log.ZWarn(ctx, "SetConnectTime failed", err, "roomID", dbInv.RoomID) + } + // 接受邀请后不删除 invitation:通话仍在进行,双方应被标记为忙线(BusyLineUserIDList)。 // invitation 的清理由以下路径负责: // - 主动挂断:handleHungUp → DeleteInvitation @@ -451,6 +461,8 @@ func (s *rtcServer) handleReject(ctx context.Context, req *rtc.SignalRejectReq, if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil { log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID) } + // For 1v1 calls, rejection means the call was never answered. + go s.writeCallRecord(context.WithoutCancel(ctx), dbInv, model.CallStatusNotConnected, time.Now().UnixMilli()) } return &rtc.SignalRejectResp{}, nil @@ -488,9 +500,55 @@ func (s *rtcServer) handleCancel(ctx context.Context, req *rtc.SignalCancelReq, log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID) } + go s.writeCallRecord(context.WithoutCancel(ctx), dbInv, model.CallStatusNotConnected, time.Now().UnixMilli()) + return &rtc.SignalCancelResp{}, nil } +// handleTimeout processes a call timeout: the inviter's ring timer fired without any invitee answering. +// Semantics are similar to cancel, but the payload type is Timeout so clients can show "missed call" UI. +func (s *rtcServer) handleTimeout(ctx context.Context, req *rtc.SignalTimeoutReq, signalReq *rtc.SignalReq) (*rtc.SignalTimeoutResp, error) { + if req.Invitation == nil { + return nil, errs.ErrArgs.WrapMsg("invitation is nil") + } + + dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID) + if err != nil { + // Invitation may have been cleaned up by TTL already; treat as no-op. + if errs.ErrRecordNotFound.Is(err) { + log.ZWarn(ctx, "handleTimeout: invitation already expired or not found", nil, "roomID", req.Invitation.RoomID) + return &rtc.SignalTimeoutResp{}, nil + } + return nil, errs.WrapMsg(err, "get invitation failed", "roomID", req.Invitation.RoomID) + } + if req.UserID != dbInv.InviterUserID { + return nil, errs.ErrNoPermission.WrapMsg("only the inviter can trigger timeout", "userID", req.UserID, "inviterUserID", dbInv.InviterUserID) + } + + sessionType := int32(constant.SingleChatType) + if dbInv.GroupID != "" { + sessionType = int32(constant.ReadGroupChatType) + } + content, err := marshalSignalReq(signalReq) + if err != nil { + return nil, err + } + // Notify each invitee so they can dismiss the incoming-call UI and show "missed call". + for _, inviteeID := range dbInv.InviteeUserIDList { + if err := s.sendSignalingNotification(ctx, req.UserID, inviteeID, sessionType, dbInv.GroupID, req.OfflinePushInfo, content); err != nil { + log.ZWarn(ctx, "handleTimeout: sendSignalingNotification to invitee failed", err, "inviteeID", inviteeID) + } + } + + if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil { + log.ZWarn(ctx, "handleTimeout: DeleteInvitation failed", err, "roomID", dbInv.RoomID) + } + + go s.writeCallRecord(context.WithoutCancel(ctx), dbInv, model.CallStatusNotConnected, time.Now().UnixMilli()) + + return &rtc.SignalTimeoutResp{}, nil +} + // handleHungUp processes a call hang-up. func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, signalReq *rtc.SignalReq) (*rtc.SignalHungUpResp, error) { if req.Invitation == nil { @@ -529,6 +587,8 @@ func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID) } + go s.writeCallRecord(context.WithoutCancel(ctx), dbInv, model.CallStatusAnswered, time.Now().UnixMilli()) + return &rtc.SignalHungUpResp{}, nil } @@ -765,6 +825,95 @@ func (s *rtcServer) DeleteSignalRecords(ctx context.Context, req *rtc.DeleteSign return &rtc.DeleteSignalRecordsResp{}, nil } +// GetCallRecords returns paginated call records for a user. +// status=0 returns all records; status=1 returns answered calls; status=2 returns not-connected calls. +// For 1v1 calls, InviterUserNickname is resolved per-viewer with priority: remark > firstName+lastName > nickname. +func (s *rtcServer) GetCallRecords(ctx context.Context, req *rtc.GetCallRecordsReq) (*rtc.GetCallRecordsResp, error) { + if req.UserID == "" { + req.UserID = mcontext.GetOpUserID(ctx) + } + total, records, err := s.db.SearchCallRecords(ctx, req.UserID, req.Status, req.StartTime, req.EndTime, req.Keyword, req.Pagination) + if err != nil { + return nil, err + } + + // For 1v1 calls, resolve InviterUserNickname from the querying user's perspective: + // remark (if friend) > firstName + lastName > nickname. + // Collect unique inviter IDs that appear in 1v1 records. + inviterIDSet := make(map[string]struct{}) + for _, r := range records { + if r.GroupID == "" && r.InviterUserID != "" { + inviterIDSet[r.InviterUserID] = struct{}{} + } + } + userInfoMap := make(map[string]*sdkws.UserInfo) + remarkMap := make(map[string]string) // inviterUserID → remark + if len(inviterIDSet) > 0 { + inviterIDs := make([]string, 0, len(inviterIDSet)) + for id := range inviterIDSet { + inviterIDs = append(inviterIDs, id) + } + if infoMap, e := s.userClient.GetUsersInfoMap(ctx, inviterIDs); e == nil { + userInfoMap = infoMap + } else { + log.ZWarn(ctx, "GetCallRecords: GetUsersInfoMap failed", e) + } + if friendInfos, e := s.relationClient.GetFriendsInfo(ctx, req.UserID, inviterIDs); e == nil { + for _, f := range friendInfos { + if f.GetRemark() != "" { + remarkMap[f.GetFriendUserID()] = f.GetRemark() + } + } + } else { + log.ZWarn(ctx, "GetCallRecords: GetFriendsInfo failed", e) + } + } + + items := make([]*rtc.CallRecordItem, 0, len(records)) + for _, r := range records { + direction := model.CallDirectionIncoming + if r.InviterUserID == req.UserID { + direction = model.CallDirectionOutgoing + } + + inviterNickname := r.InviterUserNickname + if r.GroupID == "" && r.InviterUserID != "" { + if remark, ok := remarkMap[r.InviterUserID]; ok { + inviterNickname = remark + } else if ui, ok := userInfoMap[r.InviterUserID]; ok { + if name := strings.TrimSpace(ui.FirstName + " " + ui.LastName); name != "" { + inviterNickname = name + } else { + inviterNickname = ui.Nickname + } + } + } + + items = append(items, &rtc.CallRecordItem{ + Sid: r.SID, + RoomID: r.RoomID, + Status: r.Status, + Duration: r.Duration, + DialDuration: r.DialDuration, + CallDuration: r.CallDuration, + CreateTime: r.CreateTime, + MediaType: r.MediaType, + SessionType: r.SessionType, + InviterUserID: r.InviterUserID, + InviterUserNickname: inviterNickname, + InviterUserFaceURL: r.InviterUserFaceURL, + InviteeUserIDList: r.InviteeUserIDList, + GroupID: r.GroupID, + GroupName: r.GroupName, + Direction: direction, + }) + } + return &rtc.GetCallRecordsResp{ + Total: int32(total), + Records: items, + }, nil +} + // ---- helpers ---- // genToken generates a LiveKit access token for the given room and identity. @@ -918,6 +1067,75 @@ func modelToInvitationInfo(m *model.SignalInvitation) *rtc.InvitationInfo { } } +// writeCallRecord creates a call record entry after a call ends (best-effort, logs on failure). +// status: model.CallStatusAnswered or model.CallStatusNotConnected. +// endTimeMs: Unix ms timestamp when the call ended (used to compute duration for answered calls). +func (s *rtcServer) writeCallRecord(ctx context.Context, inv *model.SignalInvitation, status int32, endTimeMs int64) { + sid := fmt.Sprintf("call-%s", uuid.New().String()) + + // totalDuration: kept for backward compatibility (initiate → end). + var totalDuration, dialDuration, callDuration int64 + if inv.InitiateTime > 0 { + if status == model.CallStatusAnswered && inv.ConnectTime > 0 { + // 拨打时长 = 振铃到接听 + dialDuration = (inv.ConnectTime - inv.InitiateTime) / 1000 + // 通话时长 = 接听到挂断 + callDuration = (endTimeMs - inv.ConnectTime) / 1000 + totalDuration = dialDuration + callDuration + } else { + // 未接通:全程视为拨打时长 + dialDuration = (endTimeMs - inv.InitiateTime) / 1000 + } + if dialDuration < 0 { + dialDuration = 0 + } + if callDuration < 0 { + callDuration = 0 + } + if totalDuration < 0 { + totalDuration = 0 + } + } + + record := &model.CallRecord{ + SID: sid, + RoomID: inv.RoomID, + Status: status, + Duration: totalDuration, + DialDuration: dialDuration, + CallDuration: callDuration, + CreateTime: inv.InitiateTime, + MediaType: inv.MediaType, + SessionType: inv.SessionType, + InviterUserID: inv.InviterUserID, + InviteeUserIDList: inv.InviteeUserIDList, + GroupID: inv.GroupID, + } + + // Fetch inviter's nickname and face URL. + if inv.InviterUserID != "" { + if userInfo, err := s.userClient.GetUserInfo(ctx, inv.InviterUserID); err == nil { + record.InviterUserNickname = userInfo.Nickname + record.InviterUserFaceURL = userInfo.FaceURL + } else { + log.ZWarn(ctx, "writeCallRecord: GetUserInfo failed", err, "inviterUserID", inv.InviterUserID) + } + } + + // Fetch group name if this is a group call. + if inv.GroupID != "" { + if groupInfo, err := s.groupClient.GetGroupInfo(ctx, inv.GroupID); err == nil { + record.GroupName = groupInfo.GroupName + } else { + log.ZWarn(ctx, "writeCallRecord: GetGroupInfo failed", err, "groupID", inv.GroupID) + } + } + + if err := s.db.CreateCallRecord(ctx, record); err != nil { + log.ZWarn(ctx, "writeCallRecord: CreateCallRecord failed", err, "roomID", inv.RoomID, "status", status) + } +} + // hungUpPeerIDsFromDB returns IDs that should receive hang-up notification, based on authoritative DB data. func hungUpPeerIDsFromDB(inv *model.SignalInvitation, callerID string) []string { if callerID == inv.InviterUserID { diff --git a/pkg/common/storage/controller/rtc.go b/pkg/common/storage/controller/rtc.go index 8e0e46557..8a5c168fe 100644 --- a/pkg/common/storage/controller/rtc.go +++ b/pkg/common/storage/controller/rtc.go @@ -29,6 +29,7 @@ type RtcDatabase interface { GetInvitationByInviteeUserID(ctx context.Context, userID string) (*model.SignalInvitation, error) DeleteInvitation(ctx context.Context, roomID string) error RemoveInvitee(ctx context.Context, roomID string, userID string) error + SetConnectTime(ctx context.Context, roomID string, connectTimeMs int64) error GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error) GetInvitationsByRoomIDs(ctx context.Context, roomIDs []string) ([]*model.SignalInvitation, error) // GetBusyUserIDs returns the subset of userIDs that are currently in an active call. @@ -37,14 +38,20 @@ type RtcDatabase interface { CreateRecord(ctx context.Context, record *model.SignalRecord) error SearchRecords(ctx context.Context, sendID, recvID string, sessionType int32, startTime, endTime int64, pagination pagination.Pagination) (int64, []*model.SignalRecord, error) DeleteRecords(ctx context.Context, sIDs []string) error + + // Call record operations (通话记录). + CreateCallRecord(ctx context.Context, record *model.CallRecord) error + SearchCallRecords(ctx context.Context, userID string, status int32, startTime, endTime int64, keyword string, pg pagination.Pagination) (int64, []*model.CallRecord, error) + DeleteCallRecords(ctx context.Context, sids []string) error } type rtcDatabase struct { - db database.SignalDatabase + db database.SignalDatabase + callRecord database.CallRecordDatabase } -func NewRtcDatabase(db database.SignalDatabase) RtcDatabase { - return &rtcDatabase{db: db} +func NewRtcDatabase(db database.SignalDatabase, callRecord database.CallRecordDatabase) RtcDatabase { + return &rtcDatabase{db: db, callRecord: callRecord} } func (r *rtcDatabase) CreateInvitation(ctx context.Context, inv *model.SignalInvitation) error { @@ -67,6 +74,10 @@ func (r *rtcDatabase) RemoveInvitee(ctx context.Context, roomID string, userID s return r.db.RemoveInvitee(ctx, roomID, userID) } +func (r *rtcDatabase) SetConnectTime(ctx context.Context, roomID string, connectTimeMs int64) error { + return r.db.SetConnectTime(ctx, roomID, connectTimeMs) +} + func (r *rtcDatabase) GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error) { return r.db.GetInvitationByGroupID(ctx, groupID) } @@ -90,3 +101,15 @@ func (r *rtcDatabase) SearchRecords(ctx context.Context, sendID, recvID string, func (r *rtcDatabase) DeleteRecords(ctx context.Context, sIDs []string) error { return r.db.DeleteRecords(ctx, sIDs) } + +func (r *rtcDatabase) CreateCallRecord(ctx context.Context, record *model.CallRecord) error { + return r.callRecord.CreateCallRecord(ctx, record) +} + +func (r *rtcDatabase) SearchCallRecords(ctx context.Context, userID string, status int32, startTime, endTime int64, keyword string, pg pagination.Pagination) (int64, []*model.CallRecord, error) { + return r.callRecord.SearchCallRecords(ctx, userID, status, startTime, endTime, keyword, pg) +} + +func (r *rtcDatabase) DeleteCallRecords(ctx context.Context, sids []string) error { + return r.callRecord.DeleteCallRecords(ctx, sids) +} diff --git a/pkg/common/storage/database/call_record.go b/pkg/common/storage/database/call_record.go new file mode 100644 index 000000000..77cf92b96 --- /dev/null +++ b/pkg/common/storage/database/call_record.go @@ -0,0 +1,33 @@ +// Copyright © 2024 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "context" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/tools/db/pagination" +) + +// CallRecordDatabase defines storage operations for the call record table. +type CallRecordDatabase interface { + // CreateCallRecord writes a new call record entry. + CreateCallRecord(ctx context.Context, record *model.CallRecord) error + // SearchCallRecords returns paginated call records involving userID, + // optionally filtered by status, time range and a keyword (fuzzy match on InviterUserNickname). + SearchCallRecords(ctx context.Context, userID string, status int32, startTime, endTime int64, keyword string, pg pagination.Pagination) (int64, []*model.CallRecord, error) + // DeleteCallRecords removes call records by their SIDs. + DeleteCallRecords(ctx context.Context, sids []string) error +} diff --git a/pkg/common/storage/database/mgo/call_record.go b/pkg/common/storage/database/mgo/call_record.go new file mode 100644 index 000000000..642c11f79 --- /dev/null +++ b/pkg/common/storage/database/mgo/call_record.go @@ -0,0 +1,92 @@ +// Copyright © 2024 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mgo + +import ( + "context" + + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database" + "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model" + "github.com/openimsdk/tools/db/mongoutil" + "github.com/openimsdk/tools/db/pagination" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func NewCallRecordMongo(db *mongo.Database) (database.CallRecordDatabase, error) { + coll := db.Collection(database.CallRecordName) + _, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{ + { + Keys: bson.D{{Key: "sid", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "inviter_user_id", Value: 1}}, + }, + { + Keys: bson.D{{Key: "invitee_user_id_list", Value: 1}}, + }, + { + Keys: bson.D{{Key: "status", Value: 1}}, + }, + { + Keys: bson.D{{Key: "create_time", Value: -1}}, + }, + }) + if err != nil { + return nil, err + } + return &callRecordMgo{coll: coll}, nil +} + +type callRecordMgo struct { + coll *mongo.Collection +} + +func (c *callRecordMgo) CreateCallRecord(ctx context.Context, record *model.CallRecord) error { + return mongoutil.InsertMany(ctx, c.coll, []*model.CallRecord{record}) +} + +func (c *callRecordMgo) SearchCallRecords(ctx context.Context, userID string, status int32, startTime, endTime int64, keyword string, pg pagination.Pagination) (int64, []*model.CallRecord, error) { + filter := bson.M{} + if userID != "" { + filter["$or"] = bson.A{ + bson.M{"inviter_user_id": userID}, + bson.M{"invitee_user_id_list": userID}, + } + } + if status != 0 { + filter["status"] = status + } + if startTime > 0 || endTime > 0 { + timeFilter := bson.M{} + if startTime > 0 { + timeFilter["$gte"] = startTime + } + if endTime > 0 { + timeFilter["$lte"] = endTime + } + filter["create_time"] = timeFilter + } + if keyword != "" { + filter["inviter_user_nickname"] = bson.M{"$regex": keyword, "$options": "i"} + } + return mongoutil.FindPage[*model.CallRecord](ctx, c.coll, filter, pg, options.Find().SetSort(bson.M{"create_time": -1})) +} + +func (c *callRecordMgo) DeleteCallRecords(ctx context.Context, sids []string) error { + return mongoutil.DeleteMany(ctx, c.coll, bson.M{"sid": bson.M{"$in": sids}}) +} diff --git a/pkg/common/storage/database/mgo/signal.go b/pkg/common/storage/database/mgo/signal.go index 0eb7f8b0e..2ffd3e5ca 100644 --- a/pkg/common/storage/database/mgo/signal.go +++ b/pkg/common/storage/database/mgo/signal.go @@ -108,6 +108,14 @@ func (s *signalMgo) RemoveInvitee(ctx context.Context, roomID string, userID str return err } +func (s *signalMgo) SetConnectTime(ctx context.Context, roomID string, connectTimeMs int64) error { + _, err := s.invColl.UpdateOne(ctx, + bson.M{"room_id": roomID}, + bson.M{"$set": bson.M{"connect_time": connectTimeMs}}, + ) + return err +} + func (s *signalMgo) GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error) { opts := options.FindOne().SetSort(bson.M{"create_time": -1}) return mongoutil.FindOne[*model.SignalInvitation](ctx, s.invColl, bson.M{"group_id": groupID}, opts) diff --git a/pkg/common/storage/database/name.go b/pkg/common/storage/database/name.go index 8df4f6a74..6a1bbb8d1 100644 --- a/pkg/common/storage/database/name.go +++ b/pkg/common/storage/database/name.go @@ -23,6 +23,7 @@ const ( PhoneSNInfoName = "phone_sn_info" SignalInvitationName = "signal_invitation" SignalRecordName = "signal_record" + CallRecordName = "call_record" SpamReportName = "spam_report" MsgBurnDeadlineName = "msg_burn_deadline" UserOfflineRecordName = "user_offline_record" diff --git a/pkg/common/storage/database/signal.go b/pkg/common/storage/database/signal.go index dbc24834b..f80f186bd 100644 --- a/pkg/common/storage/database/signal.go +++ b/pkg/common/storage/database/signal.go @@ -34,6 +34,8 @@ type SignalDatabase interface { // RemoveInvitee removes a single user from the invitee list via $pull; // if the list becomes empty the document is deleted automatically. RemoveInvitee(ctx context.Context, roomID string, userID string) error + // SetConnectTime records the Unix ms timestamp when a callee first accepted the call. + SetConnectTime(ctx context.Context, roomID string, connectTimeMs int64) error // GetInvitationByGroupID retrieves the active invitation for a group. GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error) // GetInvitationsByRoomIDs retrieves invitations for the given room IDs. diff --git a/pkg/common/storage/model/signal.go b/pkg/common/storage/model/signal.go index 1dc46c9e5..6faa1cb87 100644 --- a/pkg/common/storage/model/signal.go +++ b/pkg/common/storage/model/signal.go @@ -16,6 +16,18 @@ package model import "time" +// Call record status values for CallRecord.Status. +const ( + CallStatusAnswered int32 = 1 // 已接听 + CallStatusNotConnected int32 = 2 // 未接通 +) + +// Call record direction values for the querying user's perspective. +const ( + CallDirectionOutgoing int32 = 1 // 主叫(发起方) + CallDirectionIncoming int32 = 2 // 被叫(接收方) +) + // SignalInvitation stores an ongoing or pending signal invitation, keyed by roomID. // It is created when a call is initiated and can be queried when the callee starts the app. type SignalInvitation struct { @@ -29,6 +41,8 @@ type SignalInvitation struct { PlatformID int32 `bson:"platform_id"` SessionType int32 `bson:"session_type"` InitiateTime int64 `bson:"initiate_time"` + // ConnectTime is the Unix ms timestamp when a callee accepted the call (0 until answered). + ConnectTime int64 `bson:"connect_time"` BusyLineUserIDList []string `bson:"busy_line_user_id_list"` OfflinePushTitle string `bson:"offline_push_title"` OfflinePushDesc string `bson:"offline_push_desc"` @@ -39,6 +53,25 @@ type SignalInvitation struct { ExpireAt time.Time `bson:"expire_at"` } +// CallRecord stores a completed call event (answered or not connected) for call history. +type CallRecord struct { + SID string `bson:"sid"` + RoomID string `bson:"room_id"` + Status int32 `bson:"status"` // CallStatusAnswered / CallStatusNotConnected + Duration int64 `bson:"duration"` // total duration in seconds (initiate→end); kept for backward compat + DialDuration int64 `bson:"dial_duration"` // 拨打时长: initiate→connect (answered) or initiate→end (not connected), seconds + CallDuration int64 `bson:"call_duration"` // 通话时长: connect→end for answered calls; 0 if not connected, seconds + CreateTime int64 `bson:"create_time"` // Unix ms, when the call was initiated + MediaType string `bson:"media_type"` // "audio" or "video" + SessionType int32 `bson:"session_type"` + InviterUserID string `bson:"inviter_user_id"` + InviterUserNickname string `bson:"inviter_user_nickname"` + InviterUserFaceURL string `bson:"inviter_user_face_url"` + InviteeUserIDList []string `bson:"invitee_user_id_list"` // all invitees (for search by participant) + GroupID string `bson:"group_id"` + GroupName string `bson:"group_name"` +} + // SignalRecord stores a completed call record used for history queries. type SignalRecord struct { SID string `bson:"sid"` diff --git a/protocol b/protocol index bf66521b2..d52af007a 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit bf66521b27c2302dcae38ff441b035cd443edf20 +Subproject commit d52af007aa8c2024f53f1c376be8501b73eea1b7