复制项目

This commit is contained in:
kim.dev.6789
2026-01-14 22:35:45 +08:00
parent 305d526110
commit b7f8db7d08
297 changed files with 81784 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
"encoding/json"
"fmt"
"git.imall.cloud/openim/chat/pkg/common/constant"
"git.imall.cloud/openim/chat/pkg/eerrs"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
constantpb "git.imall.cloud/openim/protocol/constant"
"github.com/openimsdk/tools/errs"
)
type CallbackBeforeAddFriendReq struct {
CallbackCommand `json:"callbackCommand"`
FromUserID string `json:"fromUserID" `
ToUserID string `json:"toUserID"`
ReqMsg string `json:"reqMsg"`
OperationID string `json:"operationID"`
}
type CallbackCommand string
func (c CallbackCommand) GetCallbackCommand() string {
return string(c)
}
func (o *chatSvr) OpenIMCallback(ctx context.Context, req *chat.OpenIMCallbackReq) (*chat.OpenIMCallbackResp, error) {
switch req.Command {
case constantpb.CallbackBeforeAddFriendCommand:
var data CallbackBeforeAddFriendReq
if err := json.Unmarshal([]byte(req.Body), &data); err != nil {
return nil, errs.Wrap(err)
}
user, err := o.Database.TakeAttributeByUserID(ctx, data.ToUserID)
if err != nil {
return nil, err
}
if user.AllowAddFriend != constant.OrdinaryUserAddFriendEnable {
return nil, eerrs.ErrRefuseFriend.WrapMsg(fmt.Sprintf("state %d", user.AllowAddFriend))
}
return &chat.OpenIMCallbackResp{}, nil
default:
return nil, errs.ErrArgs.WrapMsg(fmt.Sprintf("invalid command %s", req.Command))
}
}

View File

@@ -0,0 +1,292 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
"time"
chatdb "git.imall.cloud/openim/chat/pkg/common/db/table/chat"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/openimsdk/tools/errs"
)
// ==================== 收藏相关 RPC ====================
// CreateFavorite 创建收藏
func (o *chatSvr) CreateFavorite(ctx context.Context, req *chat.CreateFavoriteReq) (*chat.CreateFavoriteResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 验证收藏类型
if req.Type < 1 || req.Type > 7 {
return nil, errs.ErrArgs.WrapMsg("invalid favorite type")
}
// 创建收藏对象
favorite := &chatdb.Favorite{
UserID: userID,
Type: req.Type,
Title: req.Title,
Content: req.Content,
Description: req.Description,
Thumbnail: req.Thumbnail,
LinkURL: req.LinkURL,
FileSize: req.FileSize,
Duration: req.Duration,
Location: req.Location,
Tags: req.Tags,
Remark: req.Remark,
Status: 1, // 正常状态
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
// 保存到数据库
if err := o.Database.CreateFavorite(ctx, favorite); err != nil {
return nil, err
}
return &chat.CreateFavoriteResp{
FavoriteID: favorite.ID,
}, nil
}
// GetFavorite 获取收藏详情
func (o *chatSvr) GetFavorite(ctx context.Context, req *chat.GetFavoriteReq) (*chat.GetFavoriteResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取收藏
favorite, err := o.Database.GetFavorite(ctx, req.FavoriteID)
if err != nil {
return nil, err
}
// 验证是否为当前用户的收藏
if favorite.UserID != userID {
return nil, errs.ErrNoPermission.WrapMsg("not your favorite")
}
return &chat.GetFavoriteResp{
Favorite: convertFavoriteToProto(favorite),
}, nil
}
// GetFavorites 获取收藏列表
func (o *chatSvr) GetFavorites(ctx context.Context, req *chat.GetFavoritesReq) (*chat.GetFavoritesResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
var total int64
var favorites []*chatdb.Favorite
if req.Type > 0 {
// 按类型查询
total, favorites, err = o.Database.GetFavoritesByUserIDAndType(ctx, userID, req.Type, req.Pagination)
} else {
// 查询所有
total, favorites, err = o.Database.GetFavoritesByUserID(ctx, userID, req.Pagination)
}
if err != nil {
return nil, err
}
// 转换为响应格式
favoriteInfos := make([]*chat.FavoriteInfo, 0, len(favorites))
for _, fav := range favorites {
favoriteInfos = append(favoriteInfos, convertFavoriteToProto(fav))
}
return &chat.GetFavoritesResp{
Total: uint32(total),
Favorites: favoriteInfos,
}, nil
}
// SearchFavorites 搜索收藏
func (o *chatSvr) SearchFavorites(ctx context.Context, req *chat.SearchFavoritesReq) (*chat.SearchFavoritesResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 搜索收藏
total, favorites, err := o.Database.SearchFavoritesByKeyword(ctx, userID, req.Keyword, req.Pagination)
if err != nil {
return nil, err
}
// 转换为响应格式
favoriteInfos := make([]*chat.FavoriteInfo, 0, len(favorites))
for _, fav := range favorites {
favoriteInfos = append(favoriteInfos, convertFavoriteToProto(fav))
}
return &chat.SearchFavoritesResp{
Total: uint32(total),
Favorites: favoriteInfos,
}, nil
}
// UpdateFavorite 更新收藏
func (o *chatSvr) UpdateFavorite(ctx context.Context, req *chat.UpdateFavoriteReq) (*chat.UpdateFavoriteResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取收藏,验证所有权
favorite, err := o.Database.GetFavorite(ctx, req.FavoriteID)
if err != nil {
return nil, err
}
if favorite.UserID != userID {
return nil, errs.ErrNoPermission.WrapMsg("not your favorite")
}
// 构建更新数据
updateData := make(map[string]any)
if req.Title != "" {
updateData["title"] = req.Title
}
if req.Description != "" {
updateData["description"] = req.Description
}
if req.Remark != "" {
updateData["remark"] = req.Remark
}
if len(req.Tags) > 0 {
updateData["tags"] = req.Tags
}
// 更新收藏
if err := o.Database.UpdateFavorite(ctx, req.FavoriteID, updateData); err != nil {
return nil, err
}
return &chat.UpdateFavoriteResp{}, nil
}
// DeleteFavorite 删除收藏
func (o *chatSvr) DeleteFavorite(ctx context.Context, req *chat.DeleteFavoriteReq) (*chat.DeleteFavoriteResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 验证所有权(批量验证)
for _, favoriteID := range req.FavoriteIDs {
favorite, err := o.Database.GetFavorite(ctx, favoriteID)
if err != nil {
return nil, err
}
if favorite.UserID != userID {
return nil, errs.ErrNoPermission.WrapMsg("not your favorite")
}
}
// 删除收藏
if err := o.Database.DeleteFavorite(ctx, req.FavoriteIDs); err != nil {
return nil, err
}
return &chat.DeleteFavoriteResp{}, nil
}
// GetFavoritesByTags 根据标签获取收藏
func (o *chatSvr) GetFavoritesByTags(ctx context.Context, req *chat.GetFavoritesByTagsReq) (*chat.GetFavoritesByTagsResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
if len(req.Tags) == 0 {
return nil, errs.ErrArgs.WrapMsg("tags is empty")
}
// 根据标签查询
total, favorites, err := o.Database.GetFavoritesByTags(ctx, userID, req.Tags, req.Pagination)
if err != nil {
return nil, err
}
// 转换为响应格式
favoriteInfos := make([]*chat.FavoriteInfo, 0, len(favorites))
for _, fav := range favorites {
favoriteInfos = append(favoriteInfos, convertFavoriteToProto(fav))
}
return &chat.GetFavoritesByTagsResp{
Total: uint32(total),
Favorites: favoriteInfos,
}, nil
}
// GetFavoriteCount 获取收藏数量
func (o *chatSvr) GetFavoriteCount(ctx context.Context, req *chat.GetFavoriteCountReq) (*chat.GetFavoriteCountResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取收藏数量
count, err := o.Database.CountFavoritesByUserID(ctx, userID)
if err != nil {
return nil, err
}
return &chat.GetFavoriteCountResp{
Count: count,
}, nil
}
// convertFavoriteToProto 将数据库模型转换为 protobuf 消息
func convertFavoriteToProto(fav *chatdb.Favorite) *chat.FavoriteInfo {
return &chat.FavoriteInfo{
Id: fav.ID,
UserID: fav.UserID,
Type: fav.Type,
Title: fav.Title,
Content: fav.Content,
Description: fav.Description,
Thumbnail: fav.Thumbnail,
LinkURL: fav.LinkURL,
FileSize: fav.FileSize,
Duration: fav.Duration,
Location: fav.Location,
Tags: fav.Tags,
Remark: fav.Remark,
CreateTime: fav.CreateTime.UnixMilli(),
UpdateTime: fav.UpdateTime.UnixMilli(),
}
}

1495
internal/rpc/chat/login.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
"github.com/openimsdk/tools/errs"
"git.imall.cloud/openim/chat/pkg/common/constant"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
)
func (o *chatSvr) ResetPassword(ctx context.Context, req *chat.ResetPasswordReq) (*chat.ResetPasswordResp, error) {
if req.Password == "" {
return nil, errs.ErrArgs.WrapMsg("password must be set")
}
if req.AreaCode == "" || req.PhoneNumber == "" {
if !(req.AreaCode == "" && req.PhoneNumber == "") {
return nil, errs.ErrArgs.WrapMsg("area code and phone number must set together")
}
}
var verifyCodeID string
var err error
if req.Email == "" {
verifyCodeID, err = o.verifyCode(ctx, o.verifyCodeJoin(req.AreaCode, req.PhoneNumber), req.VerifyCode, phone)
} else {
verifyCodeID, err = o.verifyCode(ctx, req.Email, req.VerifyCode, mail)
}
if err != nil {
return nil, err
}
var account string
if req.Email == "" {
account = BuildCredentialPhone(req.AreaCode, req.PhoneNumber)
} else {
account = req.Email
}
cred, err := o.Database.TakeCredentialByAccount(ctx, account)
if err != nil {
return nil, err
}
err = o.Database.UpdatePasswordAndDeleteVerifyCode(ctx, cred.UserID, req.Password, verifyCodeID)
if err != nil {
return nil, err
}
return &chat.ResetPasswordResp{}, nil
}
func (o *chatSvr) ChangePassword(ctx context.Context, req *chat.ChangePasswordReq) (*chat.ChangePasswordResp, error) {
if req.NewPassword == "" {
return nil, errs.ErrArgs.WrapMsg("new password must be set")
}
if req.NewPassword == req.CurrentPassword {
return nil, errs.ErrArgs.WrapMsg("new password == current password")
}
opUserID, userType, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
switch userType {
case constant.NormalUser:
if req.UserID == "" {
req.UserID = opUserID
}
if req.UserID != opUserID {
return nil, errs.ErrNoPermission.WrapMsg("no permission change other user password")
}
case constant.AdminUser:
if req.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("user id must be set")
}
default:
return nil, errs.ErrInternalServer.WrapMsg("invalid user type")
}
user, err := o.Database.GetUser(ctx, req.UserID)
if err != nil {
return nil, err
}
if userType != constant.AdminUser {
if user.Password != req.CurrentPassword {
return nil, errs.ErrNoPermission.WrapMsg("current password is wrong")
}
}
if user.Password != req.NewPassword {
if err := o.Database.UpdatePassword(ctx, req.UserID, req.NewPassword); err != nil {
return nil, err
}
}
if err := o.Admin.InvalidateToken(ctx, req.UserID); err != nil {
return nil, err
}
return &chat.ChangePasswordResp{}, nil
}

View File

@@ -0,0 +1,16 @@
package chat
import (
"context"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
)
func (o *chatSvr) SetAllowRegister(ctx context.Context, req *chat.SetAllowRegisterReq) (*chat.SetAllowRegisterResp, error) {
o.AllowRegister = req.AllowRegister
return &chat.SetAllowRegisterResp{}, nil
}
func (o *chatSvr) GetAllowRegister(ctx context.Context, req *chat.GetAllowRegisterReq) (*chat.GetAllowRegisterResp, error) {
return &chat.GetAllowRegisterResp{AllowRegister: o.AllowRegister}, nil
}

22
internal/rpc/chat/rtc.go Normal file
View File

@@ -0,0 +1,22 @@
package chat
import (
"context"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
)
func (o *chatSvr) GetTokenForVideoMeeting(ctx context.Context, req *chat.GetTokenForVideoMeetingReq) (*chat.GetTokenForVideoMeetingResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
token, err := o.Livekit.GetLiveKitToken(req.Room, req.Identity)
if err != nil {
return nil, err
}
return &chat.GetTokenForVideoMeetingResp{
ServerUrl: o.Livekit.GetLiveKitURL(),
Token: token,
}, err
}

View File

@@ -0,0 +1,288 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
"time"
chatdb "git.imall.cloud/openim/chat/pkg/common/db/table/chat"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/openimsdk/tools/errs"
)
// ==================== 定时任务相关 RPC ====================
// CreateScheduledTask 创建定时任务
func (o *chatSvr) CreateScheduledTask(ctx context.Context, req *chat.CreateScheduledTaskReq) (*chat.CreateScheduledTaskResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 验证必填字段
if req.Name == "" {
return nil, errs.ErrArgs.WrapMsg("task name is required")
}
if req.CronExpression == "" {
return nil, errs.ErrArgs.WrapMsg("cron expression is required")
}
if len(req.Messages) == 0 {
return nil, errs.ErrArgs.WrapMsg("messages is required")
}
if len(req.RecvIDs) == 0 && len(req.GroupIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("recvIDs or groupIDs is required")
}
// 验证消息类型
for _, msg := range req.Messages {
if msg.Type < 1 || msg.Type > 3 {
return nil, errs.ErrArgs.WrapMsg("invalid message type")
}
}
// 转换消息列表
messages := make([]chatdb.Message, 0, len(req.Messages))
for _, msg := range req.Messages {
messages = append(messages, chatdb.Message{
Type: msg.Type,
Content: msg.Content,
Thumbnail: msg.Thumbnail,
Duration: msg.Duration,
FileSize: msg.FileSize,
Width: msg.Width,
Height: msg.Height,
})
}
// 创建定时任务对象
task := &chatdb.ScheduledTask{
UserID: userID,
Name: req.Name,
CronExpression: req.CronExpression,
Messages: messages,
RecvIDs: req.RecvIDs,
GroupIDs: req.GroupIDs,
Status: req.Status,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
// 如果状态未设置,默认为启用
if task.Status == 0 {
task.Status = 1
}
// 保存到数据库
if err := o.Database.CreateScheduledTask(ctx, task); err != nil {
return nil, err
}
return &chat.CreateScheduledTaskResp{
TaskID: task.ID,
}, nil
}
// GetScheduledTask 获取定时任务详情
func (o *chatSvr) GetScheduledTask(ctx context.Context, req *chat.GetScheduledTaskReq) (*chat.GetScheduledTaskResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取任务
task, err := o.Database.GetScheduledTask(ctx, req.TaskID)
if err != nil {
return nil, err
}
// 验证是否为当前用户的任务
if task.UserID != userID {
return nil, errs.ErrNoPermission.WrapMsg("not your task")
}
return &chat.GetScheduledTaskResp{
Task: convertScheduledTaskToProto(task),
}, nil
}
// GetScheduledTasks 获取定时任务列表
func (o *chatSvr) GetScheduledTasks(ctx context.Context, req *chat.GetScheduledTasksReq) (*chat.GetScheduledTasksResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取任务列表
total, tasks, err := o.Database.GetScheduledTasksByUserID(ctx, userID, req.Pagination)
if err != nil {
return nil, err
}
// 转换为响应格式
taskInfos := make([]*chat.ScheduledTaskInfo, 0, len(tasks))
for _, task := range tasks {
taskInfos = append(taskInfos, convertScheduledTaskToProto(task))
}
return &chat.GetScheduledTasksResp{
Total: uint32(total),
Tasks: taskInfos,
}, nil
}
// UpdateScheduledTask 更新定时任务
func (o *chatSvr) UpdateScheduledTask(ctx context.Context, req *chat.UpdateScheduledTaskReq) (*chat.UpdateScheduledTaskResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取任务,验证所有权
task, err := o.Database.GetScheduledTask(ctx, req.TaskID)
if err != nil {
return nil, err
}
if task.UserID != userID {
return nil, errs.ErrNoPermission.WrapMsg("not your task")
}
// 构建更新数据
updateData := make(map[string]any)
if req.Name != "" {
updateData["name"] = req.Name
}
if req.CronExpression != "" {
updateData["cron_expression"] = req.CronExpression
}
if len(req.Messages) > 0 {
// 验证消息类型
for _, msg := range req.Messages {
if msg.Type < 1 || msg.Type > 3 {
return nil, errs.ErrArgs.WrapMsg("invalid message type")
}
}
// 转换消息列表
messages := make([]chatdb.Message, 0, len(req.Messages))
for _, msg := range req.Messages {
messages = append(messages, chatdb.Message{
Type: msg.Type,
Content: msg.Content,
Thumbnail: msg.Thumbnail,
Duration: msg.Duration,
FileSize: msg.FileSize,
Width: msg.Width,
Height: msg.Height,
})
}
updateData["messages"] = messages
}
if req.RecvIDs != nil {
updateData["recv_ids"] = req.RecvIDs
}
if req.GroupIDs != nil {
updateData["group_ids"] = req.GroupIDs
}
// status字段0-已禁用1-已启用允许设置为0
if req.Status == 0 || req.Status == 1 {
updateData["status"] = req.Status
}
// 验证:如果更新后没有接收者,返回错误
if req.RecvIDs != nil && req.GroupIDs != nil {
if len(req.RecvIDs) == 0 && len(req.GroupIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("recvIDs or groupIDs is required")
}
} else if req.RecvIDs != nil && len(req.RecvIDs) == 0 {
// 如果只更新了RecvIDs且为空检查GroupIDs是否也为空
if len(task.GroupIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("recvIDs or groupIDs is required")
}
} else if req.GroupIDs != nil && len(req.GroupIDs) == 0 {
// 如果只更新了GroupIDs且为空检查RecvIDs是否也为空
if len(task.RecvIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("recvIDs or groupIDs is required")
}
}
// 更新任务
if err := o.Database.UpdateScheduledTask(ctx, req.TaskID, updateData); err != nil {
return nil, err
}
return &chat.UpdateScheduledTaskResp{}, nil
}
// DeleteScheduledTask 删除定时任务
func (o *chatSvr) DeleteScheduledTask(ctx context.Context, req *chat.DeleteScheduledTaskReq) (*chat.DeleteScheduledTaskResp, error) {
// 获取当前用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 验证所有权(批量验证)
for _, taskID := range req.TaskIDs {
task, err := o.Database.GetScheduledTask(ctx, taskID)
if err != nil {
return nil, err
}
if task.UserID != userID {
return nil, errs.ErrNoPermission.WrapMsg("not your task")
}
}
// 删除任务
if err := o.Database.DeleteScheduledTask(ctx, req.TaskIDs); err != nil {
return nil, err
}
return &chat.DeleteScheduledTaskResp{}, nil
}
// convertScheduledTaskToProto 将数据库模型转换为 protobuf 消息
func convertScheduledTaskToProto(task *chatdb.ScheduledTask) *chat.ScheduledTaskInfo {
messages := make([]*chat.ScheduledTaskMessage, 0, len(task.Messages))
for _, msg := range task.Messages {
messages = append(messages, &chat.ScheduledTaskMessage{
Type: msg.Type,
Content: msg.Content,
Thumbnail: msg.Thumbnail,
Duration: msg.Duration,
FileSize: msg.FileSize,
Width: msg.Width,
Height: msg.Height,
})
}
return &chat.ScheduledTaskInfo{
Id: task.ID,
UserID: task.UserID,
Name: task.Name,
CronExpression: task.CronExpression,
Messages: messages,
RecvIDs: task.RecvIDs,
GroupIDs: task.GroupIDs,
Status: task.Status,
CreateTime: task.CreateTime.UnixMilli(),
UpdateTime: task.UpdateTime.UnixMilli(),
}
}

View File

@@ -0,0 +1,107 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
"strings"
chatdb "git.imall.cloud/openim/chat/pkg/common/db/table/chat"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
)
// ==================== 敏感词检测相关 RPC ====================
// GetSensitiveWords 获取敏感词列表
func (o *chatSvr) GetSensitiveWords(ctx context.Context, req *chat.GetSensitiveWordsReq) (*chat.GetSensitiveWordsResp, error) {
// 验证用户身份
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
// 获取启用的敏感词列表
words, err := o.Database.GetSensitiveWords(ctx)
if err != nil {
return nil, err
}
// 转换为响应格式(客户端只需要基本信息)
var wordInfos []*chat.SensitiveWordInfo
for _, word := range words {
wordInfos = append(wordInfos, &chat.SensitiveWordInfo{
Word: word.Word,
Action: word.Action,
ReplaceChar: "", // 敏感词本身没有替换字符,使用配置中的默认值
})
}
// 获取敏感词配置
config, err := o.Database.GetSensitiveWordConfig(ctx)
if err != nil {
// 如果配置不存在,使用默认值
config = &chatdb.SensitiveWordConfig{
EnableFilter: true,
ReplaceChar: "***",
}
}
return &chat.GetSensitiveWordsResp{
Words: wordInfos,
EnableFilter: config.EnableFilter,
DefaultReplaceChar: config.ReplaceChar,
}, nil
}
// CheckSensitiveWords 检测敏感词
func (o *chatSvr) CheckSensitiveWords(ctx context.Context, req *chat.CheckSensitiveWordsReq) (*chat.CheckSensitiveWordsResp, error) {
// 验证用户身份
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
// 检测敏感词
matchedWords, hasSensitive, err := o.Database.CheckSensitiveWords(ctx, req.Content)
if err != nil {
return nil, err
}
// 如果检测到敏感词,进行内容过滤
filteredContent := req.Content
var matchedWordStrings []string
if hasSensitive {
for _, word := range matchedWords {
matchedWordStrings = append(matchedWordStrings, word.Word)
// 根据处理动作进行替换
if word.Action == 1 { // 替换模式
// 获取配置中的替换字符
config, err := o.Database.GetSensitiveWordConfig(ctx)
replaceChar := "***"
if err == nil && config.ReplaceChar != "" {
replaceChar = config.ReplaceChar
}
filteredContent = strings.ReplaceAll(filteredContent, word.Word, replaceChar)
}
}
}
return &chat.CheckSensitiveWordsResp{
HasSensitive: hasSensitive,
FilteredContent: filteredContent,
MatchedWords: matchedWordStrings,
}, nil
}

View File

@@ -0,0 +1,573 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
"git.imall.cloud/openim/chat/pkg/common/db/table/chat"
"git.imall.cloud/openim/chat/pkg/common/mctx"
chatpb "git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/google/uuid"
)
// ==================== 敏感词管理相关 RPC ====================
// AddSensitiveWord 添加敏感词
func (o *chatSvr) AddSensitiveWord(ctx context.Context, req *chatpb.AddSensitiveWordReq) (*chatpb.AddSensitiveWordResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 创建敏感词对象
word := &chat.SensitiveWord{
ID: uuid.New().String(),
Word: req.Word,
Level: req.Level,
Type: req.Type,
Action: req.Action,
Status: req.Status,
Creator: getAdminUserID(ctx),
Updater: getAdminUserID(ctx),
CreateTime: time.Now(),
UpdateTime: time.Now(),
Remark: req.Remark,
}
// 保存到数据库
err := o.Database.CreateSensitiveWord(ctx, word)
if err != nil {
return nil, err
}
return &chatpb.AddSensitiveWordResp{}, nil
}
// UpdateSensitiveWord 更新敏感词
func (o *chatSvr) UpdateSensitiveWord(ctx context.Context, req *chatpb.UpdateSensitiveWordReq) (*chatpb.UpdateSensitiveWordResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 构建更新数据
data := make(map[string]any)
if req.Word != "" {
data["word"] = req.Word
}
if req.Level > 0 {
data["level"] = req.Level
}
if req.Type > 0 {
data["type"] = req.Type
}
if req.Action > 0 {
data["action"] = req.Action
}
if req.Status >= 0 {
data["status"] = req.Status
}
if req.Remark != "" {
data["remark"] = req.Remark
}
data["updater"] = getAdminUserID(ctx)
// 更新数据库
err := o.Database.UpdateSensitiveWord(ctx, req.Id, data)
if err != nil {
return nil, err
}
return &chatpb.UpdateSensitiveWordResp{}, nil
}
// DeleteSensitiveWord 删除敏感词
func (o *chatSvr) DeleteSensitiveWord(ctx context.Context, req *chatpb.DeleteSensitiveWordReq) (*chatpb.DeleteSensitiveWordResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 删除数据
err := o.Database.DeleteSensitiveWord(ctx, req.Ids)
if err != nil {
return nil, err
}
return &chatpb.DeleteSensitiveWordResp{}, nil
}
// GetSensitiveWord 获取敏感词
func (o *chatSvr) GetSensitiveWord(ctx context.Context, req *chatpb.GetSensitiveWordReq) (*chatpb.GetSensitiveWordResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 查询数据
word, err := o.Database.GetSensitiveWord(ctx, req.Id)
if err != nil {
return nil, err
}
return &chatpb.GetSensitiveWordResp{
Word: convertToSensitiveWordDetailInfo(word),
}, nil
}
// SearchSensitiveWords 搜索敏感词
func (o *chatSvr) SearchSensitiveWords(ctx context.Context, req *chatpb.SearchSensitiveWordsReq) (*chatpb.SearchSensitiveWordsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 搜索数据
total, words, err := o.Database.SearchSensitiveWords(ctx, req.Keyword, req.Action, req.Status, req.Pagination)
if err != nil {
return nil, err
}
// 转换结果
var wordInfos []*chatpb.SensitiveWordDetailInfo
for _, word := range words {
wordInfos = append(wordInfos, convertToSensitiveWordDetailInfo(word))
}
return &chatpb.SearchSensitiveWordsResp{
Total: uint32(total),
Words: wordInfos,
}, nil
}
// BatchAddSensitiveWords 批量添加敏感词
func (o *chatSvr) BatchAddSensitiveWords(ctx context.Context, req *chatpb.BatchAddSensitiveWordsReq) (*chatpb.BatchAddSensitiveWordsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 转换为数据库模型
var words []*chat.SensitiveWord
now := time.Now()
adminID := getAdminUserID(ctx)
for _, wordInfo := range req.Words {
words = append(words, &chat.SensitiveWord{
ID: uuid.New().String(),
Word: wordInfo.Word,
Level: wordInfo.Level,
Type: wordInfo.Type,
Action: wordInfo.Action,
Status: wordInfo.Status,
Creator: adminID,
Updater: adminID,
CreateTime: now,
UpdateTime: now,
Remark: wordInfo.Remark,
})
}
// 批量保存
err := o.Database.BatchAddSensitiveWords(ctx, words)
if err != nil {
return nil, err
}
return &chatpb.BatchAddSensitiveWordsResp{}, nil
}
// BatchUpdateSensitiveWords 批量更新敏感词
func (o *chatSvr) BatchUpdateSensitiveWords(ctx context.Context, req *chatpb.BatchUpdateSensitiveWordsReq) (*chatpb.BatchUpdateSensitiveWordsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 转换为数据库模型
updates := make(map[string]map[string]any)
adminID := getAdminUserID(ctx)
for id, wordInfo := range req.Updates {
data := make(map[string]any)
if wordInfo.Word != "" {
data["word"] = wordInfo.Word
}
if wordInfo.Level > 0 {
data["level"] = wordInfo.Level
}
if wordInfo.Type > 0 {
data["type"] = wordInfo.Type
}
if wordInfo.Action > 0 {
data["action"] = wordInfo.Action
}
if wordInfo.Status >= 0 {
data["status"] = wordInfo.Status
}
if wordInfo.Remark != "" {
data["remark"] = wordInfo.Remark
}
data["updater"] = adminID
updates[id] = data
}
// 批量更新
err := o.Database.BatchUpdateSensitiveWords(ctx, updates)
if err != nil {
return nil, err
}
return &chatpb.BatchUpdateSensitiveWordsResp{}, nil
}
// BatchDeleteSensitiveWords 批量删除敏感词
func (o *chatSvr) BatchDeleteSensitiveWords(ctx context.Context, req *chatpb.BatchDeleteSensitiveWordsReq) (*chatpb.BatchDeleteSensitiveWordsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 批量删除
err := o.Database.BatchDeleteSensitiveWords(ctx, req.Ids)
if err != nil {
return nil, err
}
return &chatpb.BatchDeleteSensitiveWordsResp{}, nil
}
// ==================== 敏感词分组管理相关 RPC ====================
// AddSensitiveWordGroup 添加敏感词分组
func (o *chatSvr) AddSensitiveWordGroup(ctx context.Context, req *chatpb.AddSensitiveWordGroupReq) (*chatpb.AddSensitiveWordGroupResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 创建分组对象
group := &chat.SensitiveWordGroup{
ID: primitive.NewObjectID(),
Name: req.Name,
Remark: req.Remark,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
// 保存到数据库
err := o.Database.CreateSensitiveWordGroup(ctx, group)
if err != nil {
return nil, err
}
return &chatpb.AddSensitiveWordGroupResp{}, nil
}
// UpdateSensitiveWordGroup 更新敏感词分组
func (o *chatSvr) UpdateSensitiveWordGroup(ctx context.Context, req *chatpb.UpdateSensitiveWordGroupReq) (*chatpb.UpdateSensitiveWordGroupResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 构建更新数据
data := make(map[string]any)
if req.Name != "" {
data["name"] = req.Name
}
if req.Remark != "" {
data["remark"] = req.Remark
}
// 更新数据库
err := o.Database.UpdateSensitiveWordGroup(ctx, req.Id, data)
if err != nil {
return nil, err
}
return &chatpb.UpdateSensitiveWordGroupResp{}, nil
}
// DeleteSensitiveWordGroup 删除敏感词分组
func (o *chatSvr) DeleteSensitiveWordGroup(ctx context.Context, req *chatpb.DeleteSensitiveWordGroupReq) (*chatpb.DeleteSensitiveWordGroupResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 删除数据
err := o.Database.DeleteSensitiveWordGroup(ctx, req.Ids)
if err != nil {
return nil, err
}
return &chatpb.DeleteSensitiveWordGroupResp{}, nil
}
// GetSensitiveWordGroup 获取敏感词分组
func (o *chatSvr) GetSensitiveWordGroup(ctx context.Context, req *chatpb.GetSensitiveWordGroupReq) (*chatpb.GetSensitiveWordGroupResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 查询数据
group, err := o.Database.GetSensitiveWordGroup(ctx, req.Id)
if err != nil {
return nil, err
}
return &chatpb.GetSensitiveWordGroupResp{
Group: convertToSensitiveWordGroupInfo(group),
}, nil
}
// GetAllSensitiveWordGroups 获取所有敏感词分组
func (o *chatSvr) GetAllSensitiveWordGroups(ctx context.Context, req *chatpb.GetAllSensitiveWordGroupsReq) (*chatpb.GetAllSensitiveWordGroupsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 查询数据
groups, err := o.Database.GetAllSensitiveWordGroups(ctx)
if err != nil {
return nil, err
}
// 转换结果
var groupInfos []*chatpb.SensitiveWordGroupInfo
for _, group := range groups {
groupInfos = append(groupInfos, convertToSensitiveWordGroupInfo(group))
}
return &chatpb.GetAllSensitiveWordGroupsResp{
Groups: groupInfos,
}, nil
}
// ==================== 敏感词配置管理相关 RPC ====================
// GetSensitiveWordConfig 获取敏感词配置
func (o *chatSvr) GetSensitiveWordConfig(ctx context.Context, req *chatpb.GetSensitiveWordConfigReq) (*chatpb.GetSensitiveWordConfigResp, error) {
fmt.Println("GetSensitiveWordConfig", "_________11", req)
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
fmt.Println("GetSensitiveWordConfig", "_________22", err)
return nil, err
}
fmt.Println("GetSensitiveWordConfig", "_________33")
// 查询数据
config, err := o.Database.GetSensitiveWordConfig(ctx)
if err != nil {
fmt.Println("GetSensitiveWordConfig", "_________44", err)
return nil, err
}
return &chatpb.GetSensitiveWordConfigResp{
Config: convertToSensitiveWordConfigInfo(config),
}, nil
}
// UpdateSensitiveWordConfig 更新敏感词配置
func (o *chatSvr) UpdateSensitiveWordConfig(ctx context.Context, req *chatpb.UpdateSensitiveWordConfigReq) (*chatpb.UpdateSensitiveWordConfigResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 转换为数据库模型
config := &chat.SensitiveWordConfig{
ID: req.Config.Id,
EnableFilter: req.Config.EnableFilter,
FilterMode: req.Config.FilterMode,
ReplaceChar: req.Config.ReplaceChar,
WhitelistUsers: req.Config.WhitelistUsers,
WhitelistGroups: req.Config.WhitelistGroups,
LogEnabled: req.Config.LogEnabled,
AutoApprove: req.Config.AutoApprove,
UpdateTime: time.Now(),
}
// 更新数据库
err := o.Database.UpdateSensitiveWordConfig(ctx, config)
if err != nil {
return nil, err
}
return &chatpb.UpdateSensitiveWordConfigResp{}, nil
}
// ==================== 敏感词日志管理相关 RPC ====================
// GetSensitiveWordLogs 获取敏感词日志
func (o *chatSvr) GetSensitiveWordLogs(ctx context.Context, req *chatpb.GetSensitiveWordLogsReq) (*chatpb.GetSensitiveWordLogsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 查询数据
total, logs, err := o.Database.GetSensitiveWordLogs(ctx, req.UserId, req.GroupId, req.Pagination)
if err != nil {
return nil, err
}
// 转换结果
var logInfos []*chatpb.SensitiveWordLogInfo
for _, log := range logs {
logInfos = append(logInfos, convertToSensitiveWordLogInfo(log))
}
return &chatpb.GetSensitiveWordLogsResp{
Total: uint32(total),
Logs: logInfos,
}, nil
}
// DeleteSensitiveWordLogs 删除敏感词日志
func (o *chatSvr) DeleteSensitiveWordLogs(ctx context.Context, req *chatpb.DeleteSensitiveWordLogsReq) (*chatpb.DeleteSensitiveWordLogsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 删除数据
err := o.Database.DeleteSensitiveWordLogs(ctx, req.Ids)
if err != nil {
return nil, err
}
return &chatpb.DeleteSensitiveWordLogsResp{}, nil
}
// ==================== 敏感词统计相关 RPC ====================
// GetSensitiveWordStats 获取敏感词统计
func (o *chatSvr) GetSensitiveWordStats(ctx context.Context, req *chatpb.GetSensitiveWordStatsReq) (*chatpb.GetSensitiveWordStatsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 查询数据
stats, err := o.Database.GetSensitiveWordStats(ctx)
if err != nil {
return nil, err
}
return &chatpb.GetSensitiveWordStatsResp{
Stats: &chatpb.SensitiveWordStatsInfo{
Total: stats["total"],
Enabled: stats["enabled"],
Disabled: stats["disabled"],
Replace: stats["replace"],
Block: stats["block"],
},
}, nil
}
// GetSensitiveWordLogStats 获取敏感词日志统计
func (o *chatSvr) GetSensitiveWordLogStats(ctx context.Context, req *chatpb.GetSensitiveWordLogStatsReq) (*chatpb.GetSensitiveWordLogStatsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 查询数据
startTime := time.Unix(req.StartTime, 0)
endTime := time.Unix(req.EndTime, 0)
stats, err := o.Database.GetSensitiveWordLogStats(ctx, startTime, endTime)
if err != nil {
return nil, err
}
return &chatpb.GetSensitiveWordLogStatsResp{
Stats: &chatpb.SensitiveWordLogStatsInfo{
Total: stats["total"],
Replace: stats["replace"],
Block: stats["block"],
},
}, nil
}
// ==================== 辅助函数 ====================
// getAdminUserID 获取当前管理员用户ID
func getAdminUserID(ctx context.Context) string {
userID, _ := mctx.CheckAdmin(ctx)
return userID
}
// convertToSensitiveWordDetailInfo 转换为敏感词详细信息
func convertToSensitiveWordDetailInfo(word *chat.SensitiveWord) *chatpb.SensitiveWordDetailInfo {
return &chatpb.SensitiveWordDetailInfo{
Id: word.ID,
Word: word.Word,
Level: word.Level,
Type: word.Type,
Action: word.Action,
Status: word.Status,
Creator: word.Creator,
Updater: word.Updater,
CreateTime: word.CreateTime.UnixMilli(),
UpdateTime: word.UpdateTime.UnixMilli(),
Remark: word.Remark,
}
}
// convertToSensitiveWordGroupInfo 转换为敏感词分组信息
func convertToSensitiveWordGroupInfo(group *chat.SensitiveWordGroup) *chatpb.SensitiveWordGroupInfo {
return &chatpb.SensitiveWordGroupInfo{
Id: group.ID.Hex(),
Name: group.Name,
Remark: group.Remark,
CreateTime: group.CreateTime.UnixMilli(),
UpdateTime: group.UpdateTime.UnixMilli(),
}
}
// convertToSensitiveWordConfigInfo 转换为敏感词配置信息
func convertToSensitiveWordConfigInfo(config *chat.SensitiveWordConfig) *chatpb.SensitiveWordConfigInfo {
return &chatpb.SensitiveWordConfigInfo{
Id: config.ID,
EnableFilter: config.EnableFilter,
FilterMode: config.FilterMode,
ReplaceChar: config.ReplaceChar,
WhitelistUsers: config.WhitelistUsers,
WhitelistGroups: config.WhitelistGroups,
LogEnabled: config.LogEnabled,
AutoApprove: config.AutoApprove,
UpdateTime: config.UpdateTime.UnixMilli(),
}
}
// convertToSensitiveWordLogInfo 转换为敏感词日志信息
func convertToSensitiveWordLogInfo(log *chat.SensitiveWordLog) *chatpb.SensitiveWordLogInfo {
return &chatpb.SensitiveWordLogInfo{
Id: log.ID.Hex(),
UserId: log.UserID,
GroupId: log.GroupID,
Content: log.Content,
MatchedWords: log.MatchedWords,
Action: log.Action,
ProcessedText: log.ProcessedText,
CreateTime: log.CreateTime.UnixMilli(),
}
}

119
internal/rpc/chat/start.go Normal file
View File

@@ -0,0 +1,119 @@
package chat
import (
"context"
"strings"
"time"
"git.imall.cloud/openim/chat/pkg/common/constant"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/common/rtc"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/openimsdk/tools/db/mongoutil"
"github.com/openimsdk/tools/discovery"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/mw"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"git.imall.cloud/openim/chat/pkg/common/config"
"git.imall.cloud/openim/chat/pkg/common/db/database"
"git.imall.cloud/openim/chat/pkg/email"
chatClient "git.imall.cloud/openim/chat/pkg/rpclient/chat"
"git.imall.cloud/openim/chat/pkg/sms"
"github.com/openimsdk/tools/db/redisutil"
"github.com/redis/go-redis/v9"
)
type Config struct {
RpcConfig config.Chat
RedisConfig config.Redis
MongodbConfig config.Mongo
Discovery config.Discovery
Share config.Share
}
func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryRegistry, server *grpc.Server) error {
if len(config.Share.ChatAdmin) == 0 {
return errs.New("share chat admin not configured")
}
mgocli, err := mongoutil.NewMongoDB(ctx, config.MongodbConfig.Build())
if err != nil {
return err
}
rdb, err := redisutil.NewRedisClient(ctx, config.RedisConfig.Build())
if err != nil {
return err
}
var srv chatSvr
srv.rdb = rdb
config.RpcConfig.VerifyCode.Phone.Use = strings.ToLower(config.RpcConfig.VerifyCode.Phone.Use)
config.RpcConfig.VerifyCode.Mail.Use = strings.ToLower(config.RpcConfig.VerifyCode.Mail.Use)
srv.conf = config.RpcConfig.VerifyCode
switch config.RpcConfig.VerifyCode.Phone.Use {
case "ali":
ali := config.RpcConfig.VerifyCode.Phone.Ali
srv.SMS, err = sms.NewAli(ali.Endpoint, ali.AccessKeyID, ali.AccessKeySecret, ali.SignName, ali.VerificationCodeTemplateCode)
if err != nil {
return err
}
case "bao":
bao := config.RpcConfig.VerifyCode.Phone.Bao
srv.SMS, err = sms.NewBao(bao.Endpoint, bao.AccessKeyID, bao.AccessKeySecret, bao.SignName, bao.VerificationCodeTemplateCode)
if err != nil {
return err
}
}
if mail := config.RpcConfig.VerifyCode.Mail; mail.Use == constant.VerifyMail {
srv.Mail = email.NewMail(mail.SMTPAddr, mail.SMTPPort, mail.SenderMail, mail.SenderAuthorizationCode, mail.Title)
}
srv.Database, err = database.NewChatDatabase(mgocli, rdb)
if err != nil {
return err
}
conn, err := client.GetConn(ctx, config.Discovery.RpcService.Admin, grpc.WithTransportCredentials(insecure.NewCredentials()), mw.GrpcClient())
if err != nil {
return err
}
srv.Admin = chatClient.NewAdminClient(admin.NewAdminClient(conn))
srv.Code = verifyCode{
UintTime: time.Duration(config.RpcConfig.VerifyCode.UintTime) * time.Second,
MaxCount: config.RpcConfig.VerifyCode.MaxCount,
ValidCount: config.RpcConfig.VerifyCode.ValidCount,
SuperCode: config.RpcConfig.VerifyCode.SuperCode,
ValidTime: time.Duration(config.RpcConfig.VerifyCode.ValidTime) * time.Second,
Len: config.RpcConfig.VerifyCode.Len,
}
srv.Livekit = rtc.NewLiveKit(config.RpcConfig.LiveKit.Key, config.RpcConfig.LiveKit.Secret, config.RpcConfig.LiveKit.URL)
srv.AllowRegister = config.RpcConfig.AllowRegister
chat.RegisterChatServer(server, &srv)
return nil
}
type chatSvr struct {
chat.UnimplementedChatServer
conf config.VerifyCode
Database database.ChatDatabaseInterface
Admin *chatClient.AdminClient
SMS sms.SMS
Mail email.Mail
Code verifyCode
Livekit *rtc.LiveKit
ChatAdminUserID string
AllowRegister bool
rdb redis.UniversalClient
}
func (o *chatSvr) WithAdminUser(ctx context.Context) context.Context {
return mctx.WithAdminUser(ctx, o.ChatAdminUserID)
}
type verifyCode struct {
UintTime time.Duration // sec
MaxCount int
ValidCount int
SuperCode string
ValidTime time.Duration
Len int
}

View File

@@ -0,0 +1,44 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
"time"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/openimsdk/tools/errs"
)
func (o *chatSvr) UserLoginCount(ctx context.Context, req *chat.UserLoginCountReq) (*chat.UserLoginCountResp, error) {
resp := &chat.UserLoginCountResp{}
if req.Start > req.End {
return nil, errs.ErrArgs.WrapMsg("start > end")
}
total, err := o.Database.NewUserCountTotal(ctx, nil)
if err != nil {
return nil, err
}
start := time.UnixMilli(req.Start)
end := time.UnixMilli(req.End)
count, loginCount, err := o.Database.UserLoginCountRangeEverydayTotal(ctx, &start, &end)
if err != nil {
return nil, err
}
resp.LoginCount = loginCount
resp.UnloginCount = total - loginCount
resp.Count = count
return resp, nil
}

View File

@@ -0,0 +1,46 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
chatpb "git.imall.cloud/openim/chat/pkg/protocol/chat"
)
// GetAppSystemConfigs 获取APP端配置返回所有 show_in_app=true 且 enabled=true 的配置)
func (o *chatSvr) GetAppSystemConfigs(ctx context.Context, req *chatpb.GetAppSystemConfigsReq) (*chatpb.GetAppSystemConfigsResp, error) {
// 获取所有 show_in_app=true 且 enabled=true 的配置
configs, err := o.Database.GetAppSystemConfigs(ctx)
if err != nil {
return nil, err
}
// 转换为响应格式
configInfos := make([]*chatpb.SystemConfigInfo, 0, len(configs))
for _, config := range configs {
configInfos = append(configInfos, &chatpb.SystemConfigInfo{
Key: config.Key,
Title: config.Title,
Value: config.Value,
ValueType: config.ValueType,
Description: config.Description,
})
}
return &chatpb.GetAppSystemConfigsResp{
Configs: configInfos,
}, nil
}

132
internal/rpc/chat/update.go Normal file
View File

@@ -0,0 +1,132 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"time"
"git.imall.cloud/openim/chat/pkg/common/constant"
chatdb "git.imall.cloud/openim/chat/pkg/common/db/table/chat"
"github.com/openimsdk/tools/errs"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
)
func ToDBAttributeUpdate(req *chat.UpdateUserInfoReq) (map[string]any, error) {
update := make(map[string]any)
if req.Account != nil {
update["account"] = req.Account.Value
}
if req.AreaCode != nil {
update["area_code"] = req.AreaCode.Value
}
if req.Email != nil {
update["email"] = req.Email.Value
}
if req.Nickname != nil {
if req.Nickname.Value == "" {
return nil, errs.ErrArgs.WrapMsg("nickname can not be empty")
}
update["nickname"] = req.Nickname.Value
}
if req.FaceURL != nil {
update["face_url"] = req.FaceURL.Value
}
if req.Gender != nil {
update["gender"] = req.Gender.Value
}
if req.Level != nil {
update["level"] = req.Level.Value
}
// userType 现在是 int32 类型,直接使用值
update["user_type"] = req.UserType
if req.UserFlag != nil {
update["user_flag"] = req.UserFlag.Value
}
if req.Birth != nil {
update["birth_time"] = time.UnixMilli(req.Birth.Value)
}
if req.AllowAddFriend != nil {
update["allow_add_friend"] = req.AllowAddFriend.Value
}
if req.AllowBeep != nil {
update["allow_beep"] = req.AllowBeep.Value
}
if req.AllowVibration != nil {
update["allow_vibration"] = req.AllowVibration.Value
}
if req.GlobalRecvMsgOpt != nil {
update["global_recv_msg_opt"] = req.GlobalRecvMsgOpt.Value
}
//if len(update) == 0 {
// return nil, errs.ErrArgs.WrapMsg("no update info")
//}
return update, nil
}
func ToDBCredentialUpdate(req *chat.UpdateUserInfoReq, allowChange bool) ([]*chatdb.Credential, []*chatdb.Credential, error) {
update := make([]*chatdb.Credential, 0)
del := make([]*chatdb.Credential, 0)
if req.Account != nil {
if req.Account.GetValue() == "" {
del = append(del, &chatdb.Credential{
UserID: req.UserID,
Type: constant.CredentialAccount,
})
} else {
update = append(update, &chatdb.Credential{
UserID: req.UserID,
Account: req.Account.GetValue(),
Type: constant.CredentialAccount,
AllowChange: allowChange,
})
}
}
if req.Email != nil {
if req.Email.GetValue() == "" {
del = append(del, &chatdb.Credential{
UserID: req.UserID,
Type: constant.CredentialEmail,
})
} else {
update = append(update, &chatdb.Credential{
UserID: req.UserID,
Account: req.Email.GetValue(),
Type: constant.CredentialEmail,
AllowChange: allowChange,
})
}
}
if req.PhoneNumber != nil {
if req.PhoneNumber.GetValue() == "" {
del = append(del, &chatdb.Credential{
UserID: req.UserID,
Type: constant.CredentialPhone,
})
} else {
update = append(update, &chatdb.Credential{
UserID: req.UserID,
Account: BuildCredentialPhone(req.AreaCode.GetValue(), req.PhoneNumber.GetValue()),
Type: constant.CredentialPhone,
AllowChange: allowChange,
})
}
}
return update, del, nil
}

623
internal/rpc/chat/user.go Normal file
View File

@@ -0,0 +1,623 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"context"
"errors"
"regexp"
"strconv"
"strings"
"time"
"git.imall.cloud/openim/protocol/wrapperspb"
"github.com/openimsdk/tools/utils/stringutil"
"git.imall.cloud/openim/chat/pkg/common/db/dbutil"
chatdb "git.imall.cloud/openim/chat/pkg/common/db/table/chat"
constantpb "git.imall.cloud/openim/protocol/constant"
"github.com/openimsdk/tools/mcontext"
"go.mongodb.org/mongo-driver/mongo"
"git.imall.cloud/openim/chat/pkg/common/constant"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/eerrs"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
)
func (o *chatSvr) checkUpdateInfo(ctx context.Context, req *chat.UpdateUserInfoReq) error {
if req.AreaCode != nil || req.PhoneNumber != nil {
if !(req.AreaCode != nil && req.PhoneNumber != nil) {
return errs.ErrArgs.WrapMsg("areaCode and phoneNumber must be set together")
}
if req.AreaCode.Value == "" || req.PhoneNumber.Value == "" {
if req.AreaCode.Value != req.PhoneNumber.Value {
return errs.ErrArgs.WrapMsg("areaCode and phoneNumber must be set together")
}
}
}
if req.UserID == "" {
return errs.ErrArgs.WrapMsg("user id is empty")
}
credentials, err := o.Database.TakeCredentialsByUserID(ctx, req.UserID)
if err != nil {
return err
} else if len(credentials) == 0 {
return errs.ErrArgs.WrapMsg("user not found")
}
var (
credNum, delNum, addNum = len(credentials), 0, 0
)
addFunc := func(s *wrapperspb.StringValue) {
if s != nil {
if s.Value != "" {
addNum++
}
}
}
for _, s := range []*wrapperspb.StringValue{req.Account, req.PhoneNumber, req.Email} {
addFunc(s)
}
for _, credential := range credentials {
switch credential.Type {
case constant.CredentialAccount:
if req.Account != nil {
if req.Account.Value == credential.Account {
req.Account = nil
} else if req.Account.Value == "" {
delNum += 1
}
}
case constant.CredentialPhone:
if req.PhoneNumber != nil {
phoneAccount := BuildCredentialPhone(req.AreaCode.Value, req.PhoneNumber.Value)
if phoneAccount == credential.Account {
req.AreaCode = nil
req.PhoneNumber = nil
} else if req.PhoneNumber.Value == "" {
delNum += 1
}
}
case constant.CredentialEmail:
if req.Email != nil {
if req.Email.Value == credential.Account {
req.Email = nil
} else if req.Email.Value == "" {
delNum += 1
}
}
}
}
if addNum+credNum-delNum <= 0 {
return errs.ErrArgs.WrapMsg("a login method must exist")
}
if req.PhoneNumber.GetValue() != "" {
if !strings.HasPrefix(req.AreaCode.GetValue(), "+") {
req.AreaCode.Value = "+" + req.AreaCode.Value
}
if _, err := strconv.ParseUint(req.AreaCode.Value[1:], 10, 64); err != nil {
return errs.ErrArgs.WrapMsg("area code must be number")
}
if _, err := strconv.ParseUint(req.PhoneNumber.GetValue(), 10, 64); err != nil {
return errs.ErrArgs.WrapMsg("phone number must be number")
}
phoneAccount := BuildCredentialPhone(req.AreaCode.GetValue(), req.PhoneNumber.GetValue())
existingCredential, err := o.Database.TakeCredentialByAccount(ctx, phoneAccount)
if err == nil {
// 如果手机号已存在,检查是否是当前用户的手机号
if existingCredential.UserID == req.UserID {
// 是当前用户的手机号,允许更新(实际上是相同值,不需要更新)
req.AreaCode = nil
req.PhoneNumber = nil
} else {
// 是其他用户的手机号,返回错误
return eerrs.ErrPhoneAlreadyRegister.Wrap()
}
} else if !dbutil.IsDBNotFound(err) {
return err
}
}
if req.Account.GetValue() != "" {
accountValue := req.Account.GetValue()
// 验证长度6到20位
if len(accountValue) < 6 || len(accountValue) > 20 {
return errs.ErrArgs.WrapMsg("account must be between 6 and 20 characters")
}
// 验证格式:只能包含数字、字母、下划线(_)、横线(-)
pattern := `^[a-zA-Z0-9_-]+$`
matched, err := regexp.MatchString(pattern, accountValue)
if err != nil || !matched {
return errs.ErrArgs.WrapMsg("account must contain only letters, numbers, underscores, and hyphens")
}
existingCredential, err := o.Database.TakeCredentialByAccount(ctx, accountValue)
if err == nil {
// 如果账户已存在,检查是否是当前用户的账户
if existingCredential.UserID == req.UserID {
// 是当前用户的账户,允许更新(实际上是相同值,不需要更新)
req.Account = nil
} else {
// 是其他用户的账户,返回错误
return eerrs.ErrAccountAlreadyRegister.Wrap()
}
} else if !dbutil.IsDBNotFound(err) {
return err
}
}
if req.Email.GetValue() != "" {
if !stringutil.IsValidEmail(req.Email.GetValue()) {
return errs.ErrArgs.WrapMsg("invalid email")
}
existingCredential, err := o.Database.TakeCredentialByAccount(ctx, req.Email.GetValue())
if err == nil {
// 如果邮箱已存在,检查是否是当前用户的邮箱
if existingCredential.UserID == req.UserID {
// 是当前用户的邮箱,允许更新(实际上是相同值,不需要更新)
req.Email = nil
} else {
// 是其他用户的邮箱,返回错误
return eerrs.ErrEmailAlreadyRegister.Wrap()
}
} else if !dbutil.IsDBNotFound(err) {
return err
}
}
return nil
}
func (o *chatSvr) UpdateUserInfo(ctx context.Context, req *chat.UpdateUserInfoReq) (*chat.UpdateUserInfoResp, error) {
opUserID, userType, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
if err = o.checkUpdateInfo(ctx, req); err != nil {
return nil, err
}
switch userType {
case constant.NormalUser:
if req.RegisterType != nil {
return nil, errs.ErrNoPermission.WrapMsg("registerType can not be updated")
}
if req.UserID != opUserID {
return nil, errs.ErrNoPermission.WrapMsg("only admin can update other user info")
}
// 普通用户不能修改自己的用户类型
if req.UserType != 0 {
return nil, errs.ErrNoPermission.WrapMsg("normal user can not update userType")
}
case constant.AdminUser:
// 管理员可以修改用户类型,但需要验证值
if req.UserType < 0 || req.UserType > 3 {
return nil, errs.ErrArgs.WrapMsg("invalid userType: must be 0-3")
}
default:
return nil, errs.ErrNoPermission.WrapMsg("user type error")
}
update, err := ToDBAttributeUpdate(req)
if err != nil {
return nil, err
}
if userType == constant.NormalUser {
delete(update, "user_flag")
delete(update, "user_type")
}
credUpdate, credDel, err := ToDBCredentialUpdate(req, true)
if err != nil {
return nil, err
}
if len(update) > 0 {
if err := o.Database.UpdateUseInfo(ctx, req.UserID, update, credUpdate, credDel); err != nil {
return nil, err
}
}
return &chat.UpdateUserInfoResp{}, nil
}
func (o *chatSvr) FindUserPublicInfo(ctx context.Context, req *chat.FindUserPublicInfoReq) (*chat.FindUserPublicInfoResp, error) {
if len(req.UserIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("UserIDs is empty")
}
attributes, err := o.Database.FindAttribute(ctx, req.UserIDs)
if err != nil {
return nil, err
}
return &chat.FindUserPublicInfoResp{
Users: DbToPbAttributes(attributes),
}, nil
}
func (o *chatSvr) AddUserAccount(ctx context.Context, req *chat.AddUserAccountReq) (*chat.AddUserAccountResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
if err := o.checkRegisterInfo(ctx, req.User, true); err != nil {
return nil, err
}
if req.User.UserID == "" {
for i := 0; i < 20; i++ {
userID := o.genUserID()
_, err := o.Database.GetUser(ctx, userID)
if err == nil {
continue
} else if dbutil.IsDBNotFound(err) {
req.User.UserID = userID
break
} else {
return nil, err
}
}
if req.User.UserID == "" {
return nil, errs.ErrInternalServer.WrapMsg("gen user id failed")
}
} else {
_, err := o.Database.GetUser(ctx, req.User.UserID)
if err == nil {
return nil, errs.ErrArgs.WrapMsg("appoint user id already register")
} else if !dbutil.IsDBNotFound(err) {
return nil, err
}
}
var (
credentials []*chatdb.Credential
)
if req.User.PhoneNumber != "" {
credentials = append(credentials, &chatdb.Credential{
UserID: req.User.UserID,
Account: BuildCredentialPhone(req.User.AreaCode, req.User.PhoneNumber),
Type: constant.CredentialPhone,
AllowChange: true,
})
}
if req.User.Account != "" {
credentials = append(credentials, &chatdb.Credential{
UserID: req.User.UserID,
Account: req.User.Account,
Type: constant.CredentialAccount,
AllowChange: true,
})
}
if req.User.Email != "" {
credentials = append(credentials, &chatdb.Credential{
UserID: req.User.UserID,
Account: req.User.Email,
Type: constant.CredentialEmail,
AllowChange: true,
})
}
register := &chatdb.Register{
UserID: req.User.UserID,
DeviceID: req.DeviceID,
IP: req.Ip,
Platform: constantpb.PlatformID2Name[int(req.Platform)],
AccountType: "",
Mode: constant.UserMode,
CreateTime: time.Now(),
}
account := &chatdb.Account{
UserID: req.User.UserID,
Password: req.User.Password,
OperatorUserID: mcontext.GetOpUserID(ctx),
ChangeTime: register.CreateTime,
CreateTime: register.CreateTime,
}
attribute := &chatdb.Attribute{
UserID: req.User.UserID,
Account: req.User.Account,
PhoneNumber: req.User.PhoneNumber,
AreaCode: req.User.AreaCode,
Email: req.User.Email,
Nickname: req.User.Nickname,
FaceURL: req.User.FaceURL,
Gender: req.User.Gender,
BirthTime: time.UnixMilli(req.User.Birth),
ChangeTime: register.CreateTime,
CreateTime: register.CreateTime,
AllowVibration: constant.DefaultAllowVibration,
AllowBeep: constant.DefaultAllowBeep,
AllowAddFriend: constant.DefaultAllowAddFriend,
}
if err := o.Database.RegisterUser(ctx, register, account, attribute, credentials); err != nil {
return nil, err
}
return &chat.AddUserAccountResp{}, nil
}
func (o *chatSvr) SearchUserPublicInfo(ctx context.Context, req *chat.SearchUserPublicInfoReq) (*chat.SearchUserPublicInfoResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
total, list, err := o.Database.Search(ctx, constant.FinDAllUser, req.Keyword, req.Genders, nil, nil, req.Pagination)
if err != nil {
return nil, err
}
return &chat.SearchUserPublicInfoResp{
Total: uint32(total),
Users: DbToPbAttributes(list),
}, nil
}
func (o *chatSvr) FindUserFullInfo(ctx context.Context, req *chat.FindUserFullInfoReq) (*chat.FindUserFullInfoResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
if len(req.UserIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("UserIDs is empty")
}
attributes, err := o.Database.FindAttribute(ctx, req.UserIDs)
if err != nil {
return nil, err
}
// 获取每个用户的最新登录IP
userIPMap := make(map[string]string)
for _, attr := range attributes {
ip, err := o.Database.GetLatestLoginIP(ctx, attr.UserID)
if err != nil {
// 如果获取IP失败记录错误但继续处理其他用户
continue
}
userIPMap[attr.UserID] = ip
}
return &chat.FindUserFullInfoResp{Users: DbToPbUserFullInfosWithIP(attributes, userIPMap)}, nil
}
func (o *chatSvr) SearchUserFullInfo(ctx context.Context, req *chat.SearchUserFullInfoReq) (*chat.SearchUserFullInfoResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
// 解析时间戳为 time.Time毫秒时间戳
var startTime, endTime *time.Time
if req.StartTime > 0 {
st := time.UnixMilli(req.StartTime)
startTime = &st
}
if req.EndTime > 0 {
// 将endTime加1000毫秒确保包含到当天的最后一毫秒
// 例如endTime=1727740799000 (2025-11-01 23:59:59) 会被转换为 1727740800000 (2025-11-02 00:00:00)
// 这样使用 $lt 查询时,会包含 2025-11-01 23:59:59.999 但不包含 2025-11-02 00:00:00
et := time.UnixMilli(req.EndTime + 1000)
endTime = &et
}
// 使用支持实名信息搜索的方法
total, list, err := o.Database.SearchWithRealNameAuth(ctx, req.Normal, req.Keyword, req.Genders, startTime, endTime, req.RealNameKeyword, req.IdCardKeyword, req.Pagination)
if err != nil {
return nil, err
}
// 批量获取钱包信息(用于填充实名信息)
userIDs := make([]string, 0, len(list))
for _, attr := range list {
userIDs = append(userIDs, attr.UserID)
}
walletMap := make(map[string]*chatdb.Wallet)
if len(userIDs) > 0 {
wallets, err := o.Database.GetWalletsByUserIDs(ctx, userIDs)
if err != nil {
log.ZWarn(ctx, "Failed to get wallets for user search", err, "userIDs", userIDs)
} else {
for _, wallet := range wallets {
walletMap[wallet.UserID] = wallet
}
}
}
// 获取每个用户的最新登录IP
userIPMap := make(map[string]string)
for _, attr := range list {
ip, err := o.Database.GetLatestLoginIP(ctx, attr.UserID)
if err != nil {
// 如果获取IP失败记录错误但继续处理其他用户
log.ZWarn(ctx, "Failed to get latest login IP for user", err, "userID", attr.UserID)
// 即使出错也设置空字符串确保map中有该用户的记录
userIPMap[attr.UserID] = ""
continue
}
// 记录获取到的IP用于调试
if ip != "" {
log.ZDebug(ctx, "Got latest login IP for user", "userID", attr.UserID, "ip", ip)
} else {
log.ZDebug(ctx, "No login IP found for user (empty string)", "userID", attr.UserID)
}
userIPMap[attr.UserID] = ip
}
// 统计有IP的用户数量
usersWithIP := 0
for _, ip := range userIPMap {
if ip != "" {
usersWithIP++
}
}
log.ZInfo(ctx, "User IP map summary", "totalUsers", len(list), "ipMapSize", len(userIPMap), "usersWithIP", usersWithIP)
return &chat.SearchUserFullInfoResp{
Total: uint32(total),
Users: DbToPbUserFullInfosWithRealNameAuthAndIP(list, walletMap, userIPMap),
}, nil
}
// GetUserLoginRecords 查询用户登录记录
func (o *chatSvr) GetUserLoginRecords(ctx context.Context, req *chat.GetUserLoginRecordsReq) (*chat.GetUserLoginRecordsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 查询登录记录
total, records, err := o.Database.SearchUserLoginRecords(ctx, req.UserId, req.Ip, req.Pagination)
if err != nil {
return nil, err
}
// 收集所有用户ID
userIDs := make([]string, 0, len(records))
userIDSet := make(map[string]bool)
for _, record := range records {
if !userIDSet[record.UserID] {
userIDs = append(userIDs, record.UserID)
userIDSet[record.UserID] = true
}
}
// 批量获取用户属性(头像和昵称)
userAttrMap := make(map[string]*chatdb.Attribute)
if len(userIDs) > 0 {
attributes, err := o.Database.FindAttribute(ctx, userIDs)
if err != nil {
log.ZWarn(ctx, "Failed to get user attributes for login records", err, "userIDs", userIDs)
} else {
for _, attr := range attributes {
userAttrMap[attr.UserID] = attr
}
}
}
// 转换结果
var recordInfos []*chat.UserLoginRecordInfo
for _, record := range records {
recordInfo := &chat.UserLoginRecordInfo{
UserId: record.UserID,
LoginTime: record.LoginTime.UnixMilli(),
Ip: record.IP,
DeviceId: record.DeviceID,
Platform: record.Platform,
}
// 填充用户头像和昵称
if attr, ok := userAttrMap[record.UserID]; ok {
recordInfo.FaceUrl = attr.FaceURL
recordInfo.Nickname = attr.Nickname
}
recordInfos = append(recordInfos, recordInfo)
}
return &chat.GetUserLoginRecordsResp{
Total: uint32(total),
Records: recordInfos,
}, nil
}
func (o *chatSvr) FindUserAccount(ctx context.Context, req *chat.FindUserAccountReq) (*chat.FindUserAccountResp, error) {
if len(req.UserIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("user id list must be set")
}
if _, _, err := mctx.CheckAdminOrUser(ctx); err != nil {
return nil, err
}
attributes, err := o.Database.FindAttribute(ctx, req.UserIDs)
if err != nil {
return nil, err
}
userAccountMap := make(map[string]string)
for _, attribute := range attributes {
userAccountMap[attribute.UserID] = attribute.Account
}
return &chat.FindUserAccountResp{UserAccountMap: userAccountMap}, nil
}
func (o *chatSvr) FindAccountUser(ctx context.Context, req *chat.FindAccountUserReq) (*chat.FindAccountUserResp, error) {
if len(req.Accounts) == 0 {
return nil, errs.ErrArgs.WrapMsg("account list must be set")
}
if _, _, err := mctx.CheckAdminOrUser(ctx); err != nil {
return nil, err
}
attributes, err := o.Database.FindAttribute(ctx, req.Accounts)
if err != nil {
return nil, err
}
accountUserMap := make(map[string]string)
for _, attribute := range attributes {
accountUserMap[attribute.Account] = attribute.UserID
}
return &chat.FindAccountUserResp{AccountUserMap: accountUserMap}, nil
}
func (o *chatSvr) SearchUserInfo(ctx context.Context, req *chat.SearchUserInfoReq) (*chat.SearchUserInfoResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
total, list, err := o.Database.SearchUser(ctx, req.Keyword, req.UserIDs, req.Genders, req.Pagination)
if err != nil {
return nil, err
}
return &chat.SearchUserInfoResp{
Total: uint32(total),
Users: DbToPbUserFullInfos(list),
}, nil
}
func (o *chatSvr) CheckUserExist(ctx context.Context, req *chat.CheckUserExistReq) (resp *chat.CheckUserExistResp, err error) {
if req.User == nil {
return nil, errs.ErrArgs.WrapMsg("user is nil")
}
if req.User.PhoneNumber != "" {
account, err := o.Database.TakeCredentialByAccount(ctx, BuildCredentialPhone(req.User.AreaCode, req.User.PhoneNumber))
// err != nil is not found User
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
return nil, err
}
if account != nil {
return &chat.CheckUserExistResp{Userid: account.UserID, IsRegistered: true}, nil
}
}
if req.User.Email != "" {
account, err := o.Database.TakeCredentialByAccount(ctx, req.User.AreaCode)
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
return nil, err
}
if account != nil {
return &chat.CheckUserExistResp{Userid: account.UserID, IsRegistered: true}, nil
}
}
if req.User.Account != "" {
account, err := o.Database.TakeCredentialByAccount(ctx, req.User.Account)
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
return nil, err
}
if account != nil {
return &chat.CheckUserExistResp{Userid: account.UserID, IsRegistered: true}, nil
}
}
return nil, nil
}
func (o *chatSvr) DelUserAccount(ctx context.Context, req *chat.DelUserAccountReq) (resp *chat.DelUserAccountResp, err error) {
if err := o.Database.DelUserAccount(ctx, req.UserIDs); err != nil && errs.Unwrap(err) != mongo.ErrNoDocuments {
return nil, err
}
return nil, nil
}

202
internal/rpc/chat/utils.go Normal file
View File

@@ -0,0 +1,202 @@
package chat
import (
"context"
"regexp"
"strconv"
"strings"
"git.imall.cloud/openim/chat/pkg/common/db/dbutil"
table "git.imall.cloud/openim/chat/pkg/common/db/table/chat"
"git.imall.cloud/openim/chat/pkg/eerrs"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
"git.imall.cloud/openim/chat/pkg/protocol/common"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/utils/datautil"
"github.com/openimsdk/tools/utils/stringutil"
)
func DbToPbAttribute(attribute *table.Attribute) *common.UserPublicInfo {
if attribute == nil {
return nil
}
return &common.UserPublicInfo{
UserID: attribute.UserID,
Account: attribute.Account,
Email: attribute.Email,
Nickname: attribute.Nickname,
FaceURL: attribute.FaceURL,
Gender: attribute.Gender,
Level: attribute.Level,
UserType: attribute.UserType,
}
}
func DbToPbAttributes(attributes []*table.Attribute) []*common.UserPublicInfo {
return datautil.Slice(attributes, DbToPbAttribute)
}
func DbToPbUserFullInfo(attribute *table.Attribute) *common.UserFullInfo {
return &common.UserFullInfo{
UserID: attribute.UserID,
Password: "",
Account: attribute.Account,
PhoneNumber: attribute.PhoneNumber,
AreaCode: attribute.AreaCode,
Email: attribute.Email,
Nickname: attribute.Nickname,
FaceURL: attribute.FaceURL,
Gender: attribute.Gender,
Level: attribute.Level,
UserType: attribute.UserType,
Birth: attribute.BirthTime.UnixMilli(),
AllowAddFriend: attribute.AllowAddFriend,
AllowBeep: attribute.AllowBeep,
AllowVibration: attribute.AllowVibration,
GlobalRecvMsgOpt: attribute.GlobalRecvMsgOpt,
RegisterType: attribute.RegisterType,
UserFlag: attribute.UserFlag,
CreateTime: attribute.CreateTime.UnixMilli(),
Ip: "", // 默认空字符串
}
}
func DbToPbUserFullInfos(attributes []*table.Attribute) []*common.UserFullInfo {
return datautil.Slice(attributes, DbToPbUserFullInfo)
}
func DbToPbUserFullInfoWithIP(attribute *table.Attribute, ip string) *common.UserFullInfo {
return &common.UserFullInfo{
UserID: attribute.UserID,
Password: "",
Account: attribute.Account,
PhoneNumber: attribute.PhoneNumber,
AreaCode: attribute.AreaCode,
Email: attribute.Email,
Nickname: attribute.Nickname,
FaceURL: attribute.FaceURL,
Gender: attribute.Gender,
Level: attribute.Level,
UserType: attribute.UserType,
Birth: attribute.BirthTime.UnixMilli(),
AllowAddFriend: attribute.AllowAddFriend,
AllowBeep: attribute.AllowBeep,
AllowVibration: attribute.AllowVibration,
GlobalRecvMsgOpt: attribute.GlobalRecvMsgOpt,
RegisterType: attribute.RegisterType,
UserFlag: attribute.UserFlag,
CreateTime: attribute.CreateTime.UnixMilli(),
Ip: ip,
}
}
func DbToPbUserFullInfosWithIP(attributes []*table.Attribute, userIPMap map[string]string) []*common.UserFullInfo {
result := make([]*common.UserFullInfo, 0, len(attributes))
for _, attr := range attributes {
ip := userIPMap[attr.UserID]
result = append(result, DbToPbUserFullInfoWithIP(attr, ip))
}
return result
}
// DbToPbUserFullInfosWithRealNameAuth 转换用户信息(包含实名认证信息)
func DbToPbUserFullInfosWithRealNameAuth(attributes []*table.Attribute, walletMap map[string]*table.Wallet) []*common.UserFullInfo {
result := make([]*common.UserFullInfo, 0, len(attributes))
for _, attr := range attributes {
userInfo := DbToPbUserFullInfo(attr)
// 填充实名认证信息
if wallet, ok := walletMap[attr.UserID]; ok {
userInfo.IdCard = wallet.RealNameAuth.IDCard
userInfo.RealName = wallet.RealNameAuth.Name
userInfo.IdCardPhotoFront = wallet.RealNameAuth.IDCardPhotoFront
userInfo.IdCardPhotoBack = wallet.RealNameAuth.IDCardPhotoBack
userInfo.AuditStatus = wallet.RealNameAuth.AuditStatus
}
result = append(result, userInfo)
}
return result
}
// DbToPbUserFullInfosWithRealNameAuthAndIP 转换用户信息包含实名认证信息和IP
func DbToPbUserFullInfosWithRealNameAuthAndIP(attributes []*table.Attribute, walletMap map[string]*table.Wallet, userIPMap map[string]string) []*common.UserFullInfo {
result := make([]*common.UserFullInfo, 0, len(attributes))
for _, attr := range attributes {
ip := userIPMap[attr.UserID]
userInfo := DbToPbUserFullInfoWithIP(attr, ip)
// 填充实名认证信息
if wallet, ok := walletMap[attr.UserID]; ok {
userInfo.IdCard = wallet.RealNameAuth.IDCard
userInfo.RealName = wallet.RealNameAuth.Name
userInfo.IdCardPhotoFront = wallet.RealNameAuth.IDCardPhotoFront
userInfo.IdCardPhotoBack = wallet.RealNameAuth.IDCardPhotoBack
userInfo.AuditStatus = wallet.RealNameAuth.AuditStatus
}
result = append(result, userInfo)
}
return result
}
func BuildCredentialPhone(areaCode, phone string) string {
return areaCode + " " + phone
}
func (o *chatSvr) checkRegisterInfo(ctx context.Context, user *chat.RegisterUserInfo, isAdmin bool) error {
if user == nil {
return errs.ErrArgs.WrapMsg("user is nil")
}
user.Account = strings.TrimSpace(user.Account)
// 如果提供了account则不需要验证phone和email
if user.Account != "" {
// account验证逻辑在后面这里直接跳过"至少需要一个账号"的检查
} else if user.Email == "" && !(user.PhoneNumber != "" && user.AreaCode != "") && !isAdmin {
// 如果没有account也没有email也没有完整的phonephoneNumber和areaCode都提供且不是管理员则报错
return errs.ErrArgs.WrapMsg("at least one valid account is required")
}
if user.PhoneNumber != "" {
if !strings.HasPrefix(user.AreaCode, "+") {
user.AreaCode = "+" + user.AreaCode
}
if _, err := strconv.ParseUint(user.AreaCode[1:], 10, 64); err != nil {
return errs.ErrArgs.WrapMsg("area code must be number")
}
if _, err := strconv.ParseUint(user.PhoneNumber, 10, 64); err != nil {
return errs.ErrArgs.WrapMsg("phone number must be number")
}
_, err := o.Database.TakeAttributeByPhone(ctx, user.AreaCode, user.PhoneNumber)
if err == nil {
return eerrs.ErrPhoneAlreadyRegister.Wrap()
} else if !dbutil.IsDBNotFound(err) {
return err
}
}
if user.Account != "" {
// 验证长度6到20位
if len(user.Account) < 6 || len(user.Account) > 20 {
return errs.ErrArgs.WrapMsg("account must be between 6 and 20 characters")
}
// 验证格式:只能包含数字、字母、下划线(_)、横线(-)
pattern := `^[a-zA-Z0-9_-]+$`
matched, err := regexp.MatchString(pattern, user.Account)
if err != nil || !matched {
return errs.ErrArgs.WrapMsg("account must contain only letters, numbers, underscores, and hyphens")
}
_, err = o.Database.TakeAttributeByAccount(ctx, user.Account)
if err == nil {
return eerrs.ErrAccountAlreadyRegister.Wrap()
} else if !dbutil.IsDBNotFound(err) {
return err
}
}
if user.Email != "" {
if !stringutil.IsValidEmail(user.Email) {
return errs.ErrArgs.WrapMsg("invalid email")
}
_, err := o.Database.TakeAttributeByAccount(ctx, user.Email)
if err == nil {
return eerrs.ErrEmailAlreadyRegister.Wrap()
} else if !dbutil.IsDBNotFound(err) {
return err
}
}
return nil
}

753
internal/rpc/chat/wallet.go Normal file
View File

@@ -0,0 +1,753 @@
// Copyright © 2023 OpenIM open source community. 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 chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
chatdb "git.imall.cloud/openim/chat/pkg/common/db/table/chat"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/common/util"
"git.imall.cloud/openim/chat/pkg/eerrs"
chatpb "git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/google/uuid"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"go.mongodb.org/mongo-driver/mongo"
)
// GetWalletBalance 获取钱包余额
func (o *chatSvr) GetWalletBalance(ctx context.Context, req *chatpb.GetWalletBalanceReq) (*chatpb.GetWalletBalanceResp, error) {
// 获取用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取钱包信息
wallet, err := o.Database.GetWallet(ctx, userID)
if err != nil {
// 如果钱包不存在返回余额为0
if errors.Is(err, mongo.ErrNoDocuments) {
return &chatpb.GetWalletBalanceResp{
Balance: 0,
}, nil
}
return nil, err
}
return &chatpb.GetWalletBalanceResp{
Balance: wallet.Balance,
}, nil
}
// GetWalletInfo 获取钱包详细信息
func (o *chatSvr) GetWalletInfo(ctx context.Context, req *chatpb.GetWalletInfoReq) (*chatpb.GetWalletInfoResp, error) {
// 获取用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取钱包信息
wallet, err := o.Database.GetWallet(ctx, userID)
if err != nil {
// 如果钱包不存在,返回默认值
if errors.Is(err, mongo.ErrNoDocuments) {
return &chatpb.GetWalletInfoResp{
Balance: 0,
WithdrawAccount: "",
WithdrawAccountType: 0,
RealNameAuth: nil,
WithdrawReceiveAccount: "",
HasPaymentPassword: false,
}, nil
}
return nil, err
}
// 转换实名认证信息
var realNameAuth *chatpb.RealNameAuthInfo
if wallet.RealNameAuth.IDCard != "" {
realNameAuth = &chatpb.RealNameAuthInfo{
IdCard: wallet.RealNameAuth.IDCard,
IdCardPhotoFront: wallet.RealNameAuth.IDCardPhotoFront,
IdCardPhotoBack: wallet.RealNameAuth.IDCardPhotoBack,
Name: wallet.RealNameAuth.Name,
AuditStatus: wallet.RealNameAuth.AuditStatus,
}
}
return &chatpb.GetWalletInfoResp{
Balance: wallet.Balance,
WithdrawAccount: wallet.WithdrawAccount,
WithdrawAccountType: wallet.WithdrawAccountType,
RealNameAuth: realNameAuth,
WithdrawReceiveAccount: wallet.WithdrawReceiveAccount,
HasPaymentPassword: wallet.PaymentPassword != "",
}, nil
}
// GetWalletBalanceRecords 获取余额明细(余额变动记录)
func (o *chatSvr) GetWalletBalanceRecords(ctx context.Context, req *chatpb.GetWalletBalanceRecordsReq) (*chatpb.GetWalletBalanceRecordsResp, error) {
// 获取用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
var total int64
var records []*chatdb.WalletBalanceRecord
// 根据类型查询或查询所有
if req.Type > 0 {
// 按类型查询
total, records, err = o.Database.GetWalletBalanceRecordsByUserIDAndType(ctx, userID, req.Type, req.Pagination)
} else {
// 查询所有
total, records, err = o.Database.GetWalletBalanceRecords(ctx, userID, req.Pagination)
}
if err != nil {
return nil, err
}
// 转换为响应格式
recordInfos := make([]*chatpb.WalletBalanceRecordInfo, 0, len(records))
for _, record := range records {
recordInfos = append(recordInfos, &chatpb.WalletBalanceRecordInfo{
Id: record.ID,
UserID: record.UserID,
Amount: record.Amount,
Type: record.Type,
BeforeBalance: record.BeforeBalance,
AfterBalance: record.AfterBalance,
OrderID: record.OrderID,
TransactionID: record.TransactionID,
RedPacketID: record.RedPacketID,
Remark: record.Remark,
CreateTime: record.CreateTime.UnixMilli(),
})
}
return &chatpb.GetWalletBalanceRecordsResp{
Total: uint32(total),
Records: recordInfos,
}, nil
}
// SetWithdrawAccount 设置提现账号
func (o *chatSvr) SetWithdrawAccount(ctx context.Context, req *chatpb.SetWithdrawAccountReq) (*chatpb.SetWithdrawAccountResp, error) {
// 获取用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 验证必填字段
if req.Account == "" {
return nil, errs.ErrArgs.WrapMsg("提现账号不能为空")
}
if req.AccountType <= 0 || req.AccountType > 3 {
return nil, errs.ErrArgs.WrapMsg("账号类型无效必须是1-支付宝2-微信3-银行卡")
}
// 更新提现账号
if err := o.Database.UpdateWalletWithdrawAccountWithType(ctx, userID, req.Account, req.AccountType); err != nil {
return nil, err
}
return &chatpb.SetWithdrawAccountResp{}, nil
}
// SetPaymentPassword 设置支付密码(首次设置或修改)
func (o *chatSvr) SetPaymentPassword(ctx context.Context, req *chatpb.SetPaymentPasswordReq) (*chatpb.SetPaymentPasswordResp, error) {
// 获取用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 验证必填字段
if req.NewPassword == "" {
return nil, errs.ErrArgs.WrapMsg("新支付密码不能为空")
}
// 清理新密码(去除首尾空格)
newPassword := strings.TrimSpace(req.NewPassword)
if newPassword == "" {
return nil, errs.ErrArgs.WrapMsg("新支付密码不能为空")
}
// 获取钱包信息
wallet, err := o.Database.GetWallet(ctx, userID)
if err != nil {
// 如果钱包不存在,创建钱包并设置支付密码
if errors.Is(err, mongo.ErrNoDocuments) {
// 首次设置,不需要验证旧密码
if req.OldPassword != "" {
return nil, errs.ErrArgs.WrapMsg("首次设置支付密码不需要提供旧密码")
}
// 创建钱包并设置支付密码
now := time.Now()
newWallet := &chatdb.Wallet{
UserID: userID,
Balance: 0,
PaymentPassword: newPassword,
CreateTime: now,
UpdateTime: now,
}
if err := o.Database.CreateWallet(ctx, newWallet); err != nil {
return nil, err
}
return &chatpb.SetPaymentPasswordResp{}, nil
}
return nil, err
}
// 钱包已存在,判断是首次设置还是修改
hasPaymentPassword := wallet.PaymentPassword != ""
if hasPaymentPassword {
// 修改支付密码,需要验证旧密码
if req.OldPassword == "" {
return nil, errs.ErrArgs.WrapMsg("修改支付密码需要提供旧密码")
}
// 清理旧密码和存储的密码(去除首尾空格)
oldPassword := strings.TrimSpace(req.OldPassword)
storedPassword := strings.TrimSpace(wallet.PaymentPassword)
if storedPassword != oldPassword {
return nil, errs.ErrArgs.WrapMsg("旧支付密码错误")
}
if newPassword == oldPassword {
return nil, errs.ErrArgs.WrapMsg("新密码不能与旧密码相同")
}
} else {
// 首次设置支付密码,不需要验证旧密码
if req.OldPassword != "" {
return nil, errs.ErrArgs.WrapMsg("首次设置支付密码不需要提供旧密码")
}
}
// 更新支付密码
if err := o.Database.UpdateWalletPaymentPassword(ctx, userID, newPassword); err != nil {
return nil, err
}
return &chatpb.SetPaymentPasswordResp{}, nil
}
// CreateWithdrawApplication 申请提现
func (o *chatSvr) CreateWithdrawApplication(ctx context.Context, req *chatpb.CreateWithdrawApplicationReq) (*chatpb.CreateWithdrawApplicationResp, error) {
// 获取用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 验证必填字段
if req.Amount <= 0 {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "提现金额必须大于0")
}
if req.PaymentPassword == "" {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "支付密码不能为空")
}
// 从数据库 SystemConfig 集合读取 withdraw_limit 配置并验证提现限额
withdrawLimitConfig, _ := o.Database.GetSystemConfig(ctx, "withdraw_limit")
if withdrawLimitConfig != nil {
// 如果配置存在但未启用,跳过验证
if !withdrawLimitConfig.Enabled {
log.ZInfo(ctx, "withdraw_limit config is disabled, skipping validation")
} else {
// 配置存在且启用,必须验证
limitValue := strings.TrimSpace(withdrawLimitConfig.Value)
if limitValue == "" {
log.ZWarn(ctx, "withdraw_limit config value is empty", nil)
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "提现限额配置错误,请联系管理员")
}
// 解析提现限制配置(格式:最低限制-最高限制,单位:元,需要转换为分)
parts := strings.Split(limitValue, "-")
if len(parts) != 2 {
log.ZWarn(ctx, "Invalid withdraw_limit config format, expected 'min-max'", nil,
"value", limitValue)
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "提现限额配置格式错误,请联系管理员")
}
minLimitStr := strings.TrimSpace(parts[0])
maxLimitStr := strings.TrimSpace(parts[1])
minLimitYuan, err1 := strconv.ParseFloat(minLimitStr, 64)
maxLimitYuan, err2 := strconv.ParseFloat(maxLimitStr, 64)
if err1 != nil || err2 != nil {
log.ZWarn(ctx, "Failed to parse withdraw_limit config values", nil,
"minLimitStr", minLimitStr,
"maxLimitStr", maxLimitStr,
"minLimitErr", err1,
"maxLimitErr", err2)
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "提现限额配置解析失败,请联系管理员")
}
// 将元转换为分乘以100
minLimit := int64(minLimitYuan * 100)
maxLimit := int64(maxLimitYuan * 100)
// 验证配置值的有效性
if minLimit <= 0 {
log.ZWarn(ctx, "Invalid withdraw_limit minLimit, must be greater than 0", nil,
"minLimitYuan", minLimitYuan,
"minLimit", minLimit)
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "提现最低限额配置无效,请联系管理员")
}
if maxLimit <= 0 {
log.ZWarn(ctx, "Invalid withdraw_limit maxLimit, must be greater than 0", nil,
"maxLimitYuan", maxLimitYuan,
"maxLimit", maxLimit)
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "提现最高限额配置无效,请联系管理员")
}
if minLimit > maxLimit {
log.ZWarn(ctx, "Invalid withdraw_limit config, minLimit > maxLimit", nil,
"minLimitYuan", minLimitYuan,
"maxLimitYuan", maxLimitYuan,
"minLimit", minLimit,
"maxLimit", maxLimit)
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "提现限额配置错误(最低限额不能大于最高限额),请联系管理员")
}
// 验证提现金额是否在限制范围内req.Amount 单位是分)
if req.Amount < minLimit {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), fmt.Sprintf("提现金额不能少于 %.2f 元(%d 分)", minLimitYuan, minLimit))
}
if req.Amount > maxLimit {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), fmt.Sprintf("提现金额不能超过 %.2f 元(%d 分)", maxLimitYuan, maxLimit))
}
log.ZInfo(ctx, "Withdraw amount validated against withdraw_limit config",
"amount", req.Amount,
"minLimit", minLimit,
"maxLimit", maxLimit)
}
}
// 清理支付密码(去除首尾空格)
paymentPassword := strings.TrimSpace(req.PaymentPassword)
if paymentPassword == "" {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "支付密码不能为空")
}
// 获取钱包信息,验证余额是否足够
wallet, err := o.Database.GetWallet(ctx, userID)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "钱包不存在,无法申请提现")
}
return nil, err
}
// 检查是否已完成实名认证
if wallet.RealNameAuth.IDCard == "" || wallet.RealNameAuth.Name == "" {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "请先完成实名认证才能申请提现")
}
// 检查实名认证审核状态必须为审核通过1才能提现
if wallet.RealNameAuth.AuditStatus != 1 {
switch wallet.RealNameAuth.AuditStatus {
case 0:
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "实名认证正在审核中,请等待审核通过后再申请提现")
case 2:
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "实名认证审核未通过,无法申请提现")
default:
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "实名认证状态异常,无法申请提现")
}
}
// 检查是否设置了支付密码
if wallet.PaymentPassword == "" {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "请先设置支付密码")
}
// 清理数据库中存储的支付密码(去除首尾空格)
storedPassword := strings.TrimSpace(wallet.PaymentPassword)
// 调试日志:打印支付密码验证信息
log.ZInfo(ctx, "支付密码验证调试",
"userID", userID,
"inputPassword", paymentPassword,
"inputPasswordLen", len(paymentPassword),
"storedPassword", storedPassword,
"storedPasswordLen", len(storedPassword),
"storedPasswordRaw", wallet.PaymentPassword,
"storedPasswordRawLen", len(wallet.PaymentPassword),
"match", storedPassword == paymentPassword,
)
// 验证支付密码
if storedPassword != paymentPassword {
log.ZWarn(ctx, "支付密码验证失败", nil,
"userID", userID,
"inputPassword", paymentPassword,
"storedPassword", storedPassword,
)
return nil, eerrs.ErrPaymentPassword.WrapMsg("支付密码错误")
}
// 检查余额是否足够
if wallet.Balance < req.Amount {
return nil, eerrs.ErrInsufficientBalance.WrapMsg("余额不足,无法申请提现")
}
// 从钱包中获取提现账号
if wallet.WithdrawAccount == "" {
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "请先在钱包中设置提现账号")
}
// 使用事务:扣减余额、创建余额变动记录、创建提现申请
applicationID := uuid.New().String()
now := time.Now()
// 扣减余额(使用负数表示扣款)
beforeBalance, afterBalance, err := o.Database.IncrementWalletBalance(ctx, userID, -req.Amount)
if err != nil {
// IncrementWalletBalance 已经返回了具体的错误信息(如余额不足),直接返回
return nil, err
}
// 创建余额变动记录
balanceRecord := &chatdb.WalletBalanceRecord{
ID: uuid.New().String(),
UserID: userID,
Amount: -req.Amount, // 负数表示减少
Type: chatdb.BalanceRecordTypeWithdraw, // 提现/提款
BeforeBalance: beforeBalance,
AfterBalance: afterBalance,
Remark: "提现申请",
CreateTime: now,
}
if err := o.Database.CreateWalletBalanceRecord(ctx, balanceRecord); err != nil {
// 如果创建记录失败,回滚余额(增加回去)
_, _, _ = o.Database.IncrementWalletBalance(ctx, userID, req.Amount)
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "创建余额变动记录失败")
}
// 创建提现申请
application := &chatdb.WithdrawApplication{
ID: applicationID,
UserID: userID,
Amount: req.Amount,
WithdrawAccount: wallet.WithdrawAccount,
WithdrawAccountType: wallet.WithdrawAccountType,
Status: chatdb.WithdrawApplicationStatusPending, // 待审核
IP: req.Ip,
DeviceID: req.DeviceID,
Platform: req.Platform,
DeviceModel: req.DeviceModel,
DeviceBrand: req.DeviceBrand,
OSVersion: req.OsVersion,
AppVersion: req.AppVersion,
Remark: "", // 备注由后台管理员填写
CreateTime: now,
UpdateTime: now,
}
// 保存提现申请
if err := o.Database.CreateWithdrawApplication(ctx, application); err != nil {
// 如果创建申请失败,回滚余额(增加回去)
// 注意:余额变动记录保留,因为余额确实已经扣减了
// 如果后续需要可以通过记录ID删除余额变动记录
_, _, _ = o.Database.IncrementWalletBalance(ctx, userID, req.Amount)
return nil, errs.NewCodeError(errs.ErrArgs.Code(), "创建提现申请失败,余额已回滚")
}
return &chatpb.CreateWithdrawApplicationResp{
ApplicationID: applicationID,
}, nil
}
// GetWithdrawApplications 获取用户的提现申请列表
func (o *chatSvr) GetWithdrawApplications(ctx context.Context, req *chatpb.GetWithdrawApplicationsReq) (*chatpb.GetWithdrawApplicationsResp, error) {
// 获取用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 获取提现申请列表
total, applications, err := o.Database.GetWithdrawApplicationsByUserID(ctx, userID, req.Pagination)
if err != nil {
return nil, err
}
// 转换为响应格式
applicationInfos := make([]*chatpb.WithdrawApplicationInfo, 0, len(applications))
for _, app := range applications {
var auditTime int64
if !app.AuditTime.IsZero() {
auditTime = app.AuditTime.UnixMilli()
}
applicationInfos = append(applicationInfos, &chatpb.WithdrawApplicationInfo{
Id: app.ID,
UserID: app.UserID,
Amount: app.Amount,
WithdrawAccount: app.WithdrawAccount,
WithdrawAccountType: app.WithdrawAccountType,
Status: app.Status,
AuditorID: app.AuditorID,
AuditTime: auditTime,
AuditRemark: app.AuditRemark,
Ip: app.IP,
DeviceID: app.DeviceID,
Platform: app.Platform,
DeviceModel: app.DeviceModel,
DeviceBrand: app.DeviceBrand,
OsVersion: app.OSVersion,
AppVersion: app.AppVersion,
Remark: app.Remark,
CreateTime: app.CreateTime.UnixMilli(),
UpdateTime: app.UpdateTime.UnixMilli(),
})
}
return &chatpb.GetWithdrawApplicationsResp{
Total: uint32(total),
Applications: applicationInfos,
}, nil
}
// RealNameAuth 实名认证
func (o *chatSvr) RealNameAuth(ctx context.Context, req *chatpb.RealNameAuthReq) (*chatpb.RealNameAuthResp, error) {
// 获取用户ID
userID, _, err := mctx.Check(ctx)
if err != nil {
return nil, err
}
// 检查用户是否已经实名认证且审核通过
wallet, err := o.Database.GetWallet(ctx, userID)
if err == nil && wallet != nil {
// 如果已经实名认证且审核状态为通过1不允许重新认证
if wallet.RealNameAuth.IDCard != "" && wallet.RealNameAuth.AuditStatus == 1 {
return nil, errs.ErrArgs.WrapMsg("您已经完成实名认证,不能重新认证")
}
} else if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
// 如果不是"文档不存在"的错误,返回错误
return nil, err
}
// 验证必填字段
if req.IdCard == "" {
return nil, errs.ErrArgs.WrapMsg("身份证号不能为空")
}
if req.Name == "" {
return nil, errs.ErrArgs.WrapMsg("真实姓名不能为空")
}
if req.IdCardPhotoFront == "" {
return nil, errs.ErrArgs.WrapMsg("身份证正面照片不能为空")
}
if req.IdCardPhotoBack == "" {
return nil, errs.ErrArgs.WrapMsg("身份证反面照片不能为空")
}
// 清理输入(去除首尾空格)
idCard := strings.TrimSpace(req.IdCard)
name := strings.TrimSpace(req.Name)
idCardPhotoFront := strings.TrimSpace(req.IdCardPhotoFront)
idCardPhotoBack := strings.TrimSpace(req.IdCardPhotoBack)
if idCard == "" || name == "" {
return nil, errs.ErrArgs.WrapMsg("身份证号和姓名不能为空")
}
if idCardPhotoFront == "" || idCardPhotoBack == "" {
return nil, errs.ErrArgs.WrapMsg("身份证正面照片和反面照片不能为空")
}
// 验证姓名只能包含中文字符(不允许英文、数字和标点符号)
chineseRegex := regexp.MustCompile(`^[\p{Han}]+$`)
if !chineseRegex.MatchString(name) {
return nil, errs.ErrArgs.WrapMsg("真实姓名只能包含中文,不能包含英文、数字或标点符号")
}
// 构建原始数据 JSON
rawData := map[string]string{
"cardNo": idCard,
"realName": name,
}
rawDataJSON, err := json.Marshal(rawData)
if err != nil {
return nil, errs.WrapMsg(err, "构建数据失败")
}
// AES 密钥32字节的十六进制字符串
aesKey := "a7f3c9e2b8d4f1a6c3e9b2d7f4a1c8e5b2d9f6a3c8e1b4d7f2a9c5e8b1d4f7a2"
// 在客户端本地加密数据(使用 AES-GCM 模式)
log.ZInfo(ctx, "开始本地加密实名认证数据", "userID", userID, "rawData", string(rawDataJSON), "idCard", idCard, "name", name)
encryptedData, err := util.EncryptRealNameAuthData(string(rawDataJSON), aesKey)
if err != nil {
log.ZError(ctx, "本地加密失败", err, "userID", userID)
return nil, errs.WrapMsg(err, "加密数据失败")
}
log.ZInfo(ctx, "本地加密成功", "userID", userID, "encryptedLength", len(encryptedData), "encryptedData", encryptedData)
// 调用验证接口(直接发送加密后的字符串)
baseURL := "http://95.40.154.128"
verifyURL := baseURL + "/idcheck"
log.ZInfo(ctx, "准备调用验证接口", "userID", userID, "url", verifyURL, "encryptedLength", len(encryptedData))
// 创建请求,请求体直接是加密后的字符串
httpReq, err := http.NewRequest("POST", verifyURL, bytes.NewBufferString(encryptedData))
if err != nil {
log.ZError(ctx, "创建验证请求失败", err, "userID", userID, "url", verifyURL)
return nil, errs.WrapMsg(err, "创建验证请求失败")
}
httpReq.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{Timeout: 30 * time.Second}
verifyResp, err := client.Do(httpReq)
if err != nil {
log.ZError(ctx, "调用验证接口失败", err, "userID", userID, "url", verifyURL)
return nil, errs.WrapMsg(err, "调用验证接口失败")
}
defer verifyResp.Body.Close()
verifyRespBody, err := io.ReadAll(verifyResp.Body)
if err != nil {
return nil, errs.WrapMsg(err, "读取验证接口响应失败")
}
log.ZInfo(ctx, "验证接口响应", "userID", userID, "statusCode", verifyResp.StatusCode, "responseBody", string(verifyRespBody), "responseLength", len(verifyRespBody))
// 检查 HTTP 状态码
if verifyResp.StatusCode != http.StatusOK {
log.ZWarn(ctx, "验证接口返回错误状态码", nil, "userID", userID, "statusCode", verifyResp.StatusCode, "response", string(verifyRespBody))
return &chatpb.RealNameAuthResp{
Success: false,
Message: fmt.Sprintf("验证请求失败,状态码: %d, 响应: %s", verifyResp.StatusCode, string(verifyRespBody)),
}, nil
}
// 解析响应(格式:{"success": bool, "data": interface{}, "error": string, "message": string}
var verifyResult struct {
Success bool `json:"success"`
Data interface{} `json:"data"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
if err := json.Unmarshal(verifyRespBody, &verifyResult); err != nil {
log.ZWarn(ctx, "解析验证接口响应失败", err, "userID", userID, "response", string(verifyRespBody))
return &chatpb.RealNameAuthResp{
Success: false,
Message: fmt.Sprintf("解析验证结果失败: %s, 响应: %s", err.Error(), string(verifyRespBody)),
}, nil
}
// 检查验证结果
if !verifyResult.Success {
errorMsg := verifyResult.Error
if errorMsg == "" {
errorMsg = verifyResult.Message
}
if errorMsg == "" {
errorMsg = "验证失败"
}
return &chatpb.RealNameAuthResp{
Success: false,
Message: errorMsg,
}, nil
}
// 验证成功,保存实名认证信息到数据库
if verifyResult.Success {
// 获取或创建钱包
_, err := o.Database.GetWallet(ctx, userID)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
// 钱包不存在,创建钱包
now := time.Now()
newWallet := &chatdb.Wallet{
UserID: userID,
Balance: 0,
RealNameAuth: chatdb.RealNameAuth{
IDCard: idCard,
Name: name,
IDCardPhotoFront: idCardPhotoFront,
IDCardPhotoBack: idCardPhotoBack,
AuditStatus: 0, // 0-未审核
},
CreateTime: now,
UpdateTime: now,
}
if err := o.Database.CreateWallet(ctx, newWallet); err != nil {
return nil, errs.WrapMsg(err, "创建钱包失败")
}
} else {
return nil, err
}
} else {
// 更新实名认证信息(重新提交后,审核状态重置为待审核)
realNameAuth := chatdb.RealNameAuth{
IDCard: idCard,
Name: name,
IDCardPhotoFront: idCardPhotoFront,
IDCardPhotoBack: idCardPhotoBack,
AuditStatus: 0, // 0-未审核(重新提交后重置为待审核状态)
}
if err := o.Database.UpdateWalletRealNameAuth(ctx, userID, realNameAuth); err != nil {
return nil, errs.WrapMsg(err, "更新实名认证信息失败")
}
log.ZInfo(ctx, "实名认证信息已更新,审核状态重置为待审核", "userID", userID, "idCard", idCard, "name", name, "auditStatus", 0)
}
log.ZInfo(ctx, "实名认证成功并已保存", "userID", userID, "idCard", idCard, "name", name)
// 获取更新后的钱包信息返回身份证照片URL
updatedWallet, err := o.Database.GetWallet(ctx, userID)
var idCardPhotoFront, idCardPhotoBack string
if err == nil && updatedWallet != nil {
idCardPhotoFront = updatedWallet.RealNameAuth.IDCardPhotoFront
idCardPhotoBack = updatedWallet.RealNameAuth.IDCardPhotoBack
}
return &chatpb.RealNameAuthResp{
Success: true,
Message: "提交成功了,请等待审核",
IdCardPhotoFront: idCardPhotoFront,
IdCardPhotoBack: idCardPhotoBack,
}, nil
}
// 这行代码永远不会执行到,因为如果 verifyResult.Success 为 false已经在前面返回了
// 但为了代码完整性保留
log.ZError(ctx, "代码逻辑错误:验证失败但未返回", nil, "userID", userID, "verifyResult", verifyResult)
return &chatpb.RealNameAuthResp{
Success: false,
Message: "验证失败",
}, nil
}