优化通话记录

This commit is contained in:
hawklin2017 2026-05-21 11:37:23 +08:00
parent 7abd7b5299
commit a11f49299c
12 changed files with 425 additions and 5 deletions

View File

@ -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

View File

@ -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)
}

View File

@ -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),

View File

@ -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 {

View File

@ -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)
}

View 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
}

View 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}})
}

View File

@ -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)

View File

@ -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"

View File

@ -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.

View File

@ -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"`

@ -1 +1 @@
Subproject commit bf66521b27c2302dcae38ff441b035cd443edf20 Subproject commit d52af007aa8c2024f53f1c376be8501b73eea1b7