优化通话记录

pull/3727/head
hawklin2017 1 week ago
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("/get_signal_invitation_records", rc.GetSignalInvitationRecords)
rtcGroup.POST("/delete_signal_records", rc.DeleteSignalRecords)
rtcGroup.POST("/get_call_records", rc.GetCallRecords)
}
// Crypto / E2EE

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

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

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

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

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

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

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

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

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

@ -1 +1 @@
Subproject commit bf66521b27c2302dcae38ff441b035cd443edf20
Subproject commit d52af007aa8c2024f53f1c376be8501b73eea1b7
Loading…
Cancel
Save