mirror of
https://github.com/openimsdk/open-im-server.git
synced 2026-07-06 11:41:10 +08:00
优化通话记录
This commit is contained in:
parent
7abd7b5299
commit
a11f49299c
@ -386,6 +386,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
|
|||||||
rtcGroup.POST("/signal_send_custom_signal", rc.SignalSendCustomSignal)
|
rtcGroup.POST("/signal_send_custom_signal", rc.SignalSendCustomSignal)
|
||||||
rtcGroup.POST("/get_signal_invitation_records", rc.GetSignalInvitationRecords)
|
rtcGroup.POST("/get_signal_invitation_records", rc.GetSignalInvitationRecords)
|
||||||
rtcGroup.POST("/delete_signal_records", rc.DeleteSignalRecords)
|
rtcGroup.POST("/delete_signal_records", rc.DeleteSignalRecords)
|
||||||
|
rtcGroup.POST("/get_call_records", rc.GetCallRecords)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crypto / E2EE
|
// Crypto / E2EE
|
||||||
|
|||||||
@ -63,3 +63,7 @@ func (o *RtcApi) GetSignalInvitationRecords(c *gin.Context) {
|
|||||||
func (o *RtcApi) DeleteSignalRecords(c *gin.Context) {
|
func (o *RtcApi) DeleteSignalRecords(c *gin.Context) {
|
||||||
a2r.Call(c, rtc.RtcServiceClient.DeleteSignalRecords, o.Client)
|
a2r.Call(c, rtc.RtcServiceClient.DeleteSignalRecords, o.Client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *RtcApi) GetCallRecords(c *gin.Context) {
|
||||||
|
a2r.Call(c, rtc.RtcServiceClient.GetCallRecords, o.Client)
|
||||||
|
}
|
||||||
|
|||||||
@ -61,6 +61,11 @@ func Start(ctx context.Context, cfg *Config, client discovery.SvcDiscoveryRegist
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
callRecordDB, err := mgo.NewCallRecordMongo(mgocli.GetDB())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
msgConn, err := client.GetConn(ctx, cfg.Share.RpcRegisterName.Msg)
|
msgConn, err := client.GetConn(ctx, cfg.Share.RpcRegisterName.Msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -91,7 +96,7 @@ func Start(ctx context.Context, cfg *Config, client discovery.SvcDiscoveryRegist
|
|||||||
|
|
||||||
s := &rtcServer{
|
s := &rtcServer{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
db: controller.NewRtcDatabase(signalDB),
|
db: controller.NewRtcDatabase(signalDB, callRecordDB),
|
||||||
roomClient: roomClient,
|
roomClient: roomClient,
|
||||||
msgClient: rpcli.NewMsgClient(msgConn),
|
msgClient: rpcli.NewMsgClient(msgConn),
|
||||||
userClient: rpcli.NewUserClient(userConn),
|
userClient: rpcli.NewUserClient(userConn),
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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)
|
r, err := s.handleGetTokenByRoomID(ctx, payload.GetTokenByRoomID)
|
||||||
resp.Payload = &rtc.SignalResp_GetTokenByRoomID{GetTokenByRoomID: r}
|
resp.Payload = &rtc.SignalResp_GetTokenByRoomID{GetTokenByRoomID: r}
|
||||||
respErr = err
|
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:
|
default:
|
||||||
return nil, errs.ErrArgs.WrapMsg("unknown signal payload type")
|
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)
|
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:通话仍在进行,双方应被标记为忙线(BusyLineUserIDList)。
|
||||||
// invitation 的清理由以下路径负责:
|
// invitation 的清理由以下路径负责:
|
||||||
// - 主动挂断:handleHungUp → DeleteInvitation
|
// - 主动挂断: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 {
|
if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil {
|
||||||
log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID)
|
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
|
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)
|
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
|
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.
|
// handleHungUp processes a call hang-up.
|
||||||
func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, signalReq *rtc.SignalReq) (*rtc.SignalHungUpResp, error) {
|
func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, signalReq *rtc.SignalReq) (*rtc.SignalHungUpResp, error) {
|
||||||
if req.Invitation == nil {
|
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)
|
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
|
return &rtc.SignalHungUpResp{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -765,6 +825,95 @@ func (s *rtcServer) DeleteSignalRecords(ctx context.Context, req *rtc.DeleteSign
|
|||||||
return &rtc.DeleteSignalRecordsResp{}, nil
|
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 ----
|
// ---- helpers ----
|
||||||
|
|
||||||
// genToken generates a LiveKit access token for the given room and identity.
|
// 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.
|
// hungUpPeerIDsFromDB returns IDs that should receive hang-up notification, based on authoritative DB data.
|
||||||
func hungUpPeerIDsFromDB(inv *model.SignalInvitation, callerID string) []string {
|
func hungUpPeerIDsFromDB(inv *model.SignalInvitation, callerID string) []string {
|
||||||
if callerID == inv.InviterUserID {
|
if callerID == inv.InviterUserID {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ type RtcDatabase interface {
|
|||||||
GetInvitationByInviteeUserID(ctx context.Context, userID string) (*model.SignalInvitation, error)
|
GetInvitationByInviteeUserID(ctx context.Context, userID string) (*model.SignalInvitation, error)
|
||||||
DeleteInvitation(ctx context.Context, roomID string) error
|
DeleteInvitation(ctx context.Context, roomID string) error
|
||||||
RemoveInvitee(ctx context.Context, roomID string, userID 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)
|
GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error)
|
||||||
GetInvitationsByRoomIDs(ctx context.Context, roomIDs []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.
|
// 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
|
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)
|
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
|
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 {
|
type rtcDatabase struct {
|
||||||
db database.SignalDatabase
|
db database.SignalDatabase
|
||||||
|
callRecord database.CallRecordDatabase
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRtcDatabase(db database.SignalDatabase) RtcDatabase {
|
func NewRtcDatabase(db database.SignalDatabase, callRecord database.CallRecordDatabase) RtcDatabase {
|
||||||
return &rtcDatabase{db: db}
|
return &rtcDatabase{db: db, callRecord: callRecord}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *rtcDatabase) CreateInvitation(ctx context.Context, inv *model.SignalInvitation) error {
|
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)
|
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) {
|
func (r *rtcDatabase) GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error) {
|
||||||
return r.db.GetInvitationByGroupID(ctx, groupID)
|
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 {
|
func (r *rtcDatabase) DeleteRecords(ctx context.Context, sIDs []string) error {
|
||||||
return r.db.DeleteRecords(ctx, sIDs)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
33
pkg/common/storage/database/call_record.go
Normal file
33
pkg/common/storage/database/call_record.go
Normal file
@ -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
|
||||||
|
}
|
||||||
92
pkg/common/storage/database/mgo/call_record.go
Normal file
92
pkg/common/storage/database/mgo/call_record.go
Normal file
@ -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}})
|
||||||
|
}
|
||||||
@ -108,6 +108,14 @@ func (s *signalMgo) RemoveInvitee(ctx context.Context, roomID string, userID str
|
|||||||
return err
|
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) {
|
func (s *signalMgo) GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error) {
|
||||||
opts := options.FindOne().SetSort(bson.M{"create_time": -1})
|
opts := options.FindOne().SetSort(bson.M{"create_time": -1})
|
||||||
return mongoutil.FindOne[*model.SignalInvitation](ctx, s.invColl, bson.M{"group_id": groupID}, opts)
|
return mongoutil.FindOne[*model.SignalInvitation](ctx, s.invColl, bson.M{"group_id": groupID}, opts)
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const (
|
|||||||
PhoneSNInfoName = "phone_sn_info"
|
PhoneSNInfoName = "phone_sn_info"
|
||||||
SignalInvitationName = "signal_invitation"
|
SignalInvitationName = "signal_invitation"
|
||||||
SignalRecordName = "signal_record"
|
SignalRecordName = "signal_record"
|
||||||
|
CallRecordName = "call_record"
|
||||||
SpamReportName = "spam_report"
|
SpamReportName = "spam_report"
|
||||||
MsgBurnDeadlineName = "msg_burn_deadline"
|
MsgBurnDeadlineName = "msg_burn_deadline"
|
||||||
UserOfflineRecordName = "user_offline_record"
|
UserOfflineRecordName = "user_offline_record"
|
||||||
|
|||||||
@ -34,6 +34,8 @@ type SignalDatabase interface {
|
|||||||
// RemoveInvitee removes a single user from the invitee list via $pull;
|
// RemoveInvitee removes a single user from the invitee list via $pull;
|
||||||
// if the list becomes empty the document is deleted automatically.
|
// if the list becomes empty the document is deleted automatically.
|
||||||
RemoveInvitee(ctx context.Context, roomID string, userID string) error
|
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 retrieves the active invitation for a group.
|
||||||
GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error)
|
GetInvitationByGroupID(ctx context.Context, groupID string) (*model.SignalInvitation, error)
|
||||||
// GetInvitationsByRoomIDs retrieves invitations for the given room IDs.
|
// GetInvitationsByRoomIDs retrieves invitations for the given room IDs.
|
||||||
|
|||||||
@ -16,6 +16,18 @@ package model
|
|||||||
|
|
||||||
import "time"
|
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.
|
// 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.
|
// It is created when a call is initiated and can be queried when the callee starts the app.
|
||||||
type SignalInvitation struct {
|
type SignalInvitation struct {
|
||||||
@ -29,6 +41,8 @@ type SignalInvitation struct {
|
|||||||
PlatformID int32 `bson:"platform_id"`
|
PlatformID int32 `bson:"platform_id"`
|
||||||
SessionType int32 `bson:"session_type"`
|
SessionType int32 `bson:"session_type"`
|
||||||
InitiateTime int64 `bson:"initiate_time"`
|
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"`
|
BusyLineUserIDList []string `bson:"busy_line_user_id_list"`
|
||||||
OfflinePushTitle string `bson:"offline_push_title"`
|
OfflinePushTitle string `bson:"offline_push_title"`
|
||||||
OfflinePushDesc string `bson:"offline_push_desc"`
|
OfflinePushDesc string `bson:"offline_push_desc"`
|
||||||
@ -39,6 +53,25 @@ type SignalInvitation struct {
|
|||||||
ExpireAt time.Time `bson:"expire_at"`
|
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.
|
// SignalRecord stores a completed call record used for history queries.
|
||||||
type SignalRecord struct {
|
type SignalRecord struct {
|
||||||
SID string `bson:"sid"`
|
SID string `bson:"sid"`
|
||||||
|
|||||||
2
protocol
2
protocol
@ -1 +1 @@
|
|||||||
Subproject commit bf66521b27c2302dcae38ff441b035cd443edf20
|
Subproject commit d52af007aa8c2024f53f1c376be8501b73eea1b7
|
||||||
Loading…
x
Reference in New Issue
Block a user