复制项目

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

1663
internal/api/admin/admin.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
package admin
import (
"encoding/json"
"reflect"
"strconv"
"time"
"git.imall.cloud/openim/chat/pkg/common/apistruct"
"git.imall.cloud/openim/chat/pkg/common/config"
"git.imall.cloud/openim/chat/pkg/common/kdisc"
"git.imall.cloud/openim/chat/pkg/common/kdisc/etcd"
"git.imall.cloud/openim/chat/version"
"github.com/gin-gonic/gin"
"github.com/openimsdk/tools/apiresp"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/utils/datautil"
"github.com/openimsdk/tools/utils/runtimeenv"
clientv3 "go.etcd.io/etcd/client/v3"
)
const (
// wait for Restart http call return
waitHttp = time.Millisecond * 200
)
type ConfigManager struct {
config *config.AllConfig
client *clientv3.Client
configPath string
runtimeEnv string
}
func NewConfigManager(cfg *config.AllConfig, client *clientv3.Client, configPath string, runtimeEnv string) *ConfigManager {
return &ConfigManager{
config: cfg,
client: client,
configPath: configPath,
runtimeEnv: runtimeEnv,
}
}
func (cm *ConfigManager) GetConfig(c *gin.Context) {
var req apistruct.GetConfigReq
if err := c.BindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
return
}
conf := cm.config.Name2Config(req.ConfigName)
if conf == nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail("config name not found").Wrap())
return
}
b, err := json.Marshal(conf)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, string(b))
}
func (cm *ConfigManager) GetConfigList(c *gin.Context) {
var resp apistruct.GetConfigListResp
resp.ConfigNames = cm.config.GetConfigNames()
resp.Environment = runtimeenv.PrintRuntimeEnvironment()
resp.Version = version.Version
apiresp.GinSuccess(c, resp)
}
func (cm *ConfigManager) SetConfig(c *gin.Context) {
if cm.config.Discovery.Enable != kdisc.ETCDCONST {
apiresp.GinError(c, errs.New("only etcd support set config").Wrap())
return
}
var req apistruct.SetConfigReq
if err := c.BindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
return
}
var err error
switch req.ConfigName {
case config.DiscoveryConfigFileName:
err = compareAndSave[config.Discovery](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
case config.LogConfigFileName:
err = compareAndSave[config.Log](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
case config.MongodbConfigFileName:
err = compareAndSave[config.Mongo](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
case config.ChatAPIAdminCfgFileName:
err = compareAndSave[config.API](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
case config.ChatAPIChatCfgFileName:
err = compareAndSave[config.API](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
case config.ChatRPCAdminCfgFileName:
err = compareAndSave[config.Admin](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
case config.ChatRPCChatCfgFileName:
err = compareAndSave[config.Chat](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
case config.ShareFileName:
err = compareAndSave[config.Share](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
case config.RedisConfigFileName:
err = compareAndSave[config.Redis](c, cm.config.Name2Config(req.ConfigName), &req, cm.client)
default:
apiresp.GinError(c, errs.ErrArgs.Wrap())
return
}
if err != nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
return
}
apiresp.GinSuccess(c, nil)
}
func (cm *ConfigManager) SetConfigs(c *gin.Context) {
if cm.config.Discovery.Enable != kdisc.ETCDCONST {
apiresp.GinError(c, errs.New("only etcd support set config").Wrap())
return
}
var req apistruct.SetConfigsReq
if err := c.BindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
return
}
var (
err error
ops []*clientv3.Op
)
for _, cf := range req.Configs {
var op *clientv3.Op
switch cf.ConfigName {
case config.DiscoveryConfigFileName:
op, err = compareAndOp[config.Discovery](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
case config.LogConfigFileName:
op, err = compareAndOp[config.Log](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
case config.MongodbConfigFileName:
op, err = compareAndOp[config.Mongo](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
case config.ChatAPIAdminCfgFileName:
op, err = compareAndOp[config.API](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
case config.ChatAPIChatCfgFileName:
op, err = compareAndOp[config.API](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
case config.ChatRPCAdminCfgFileName:
op, err = compareAndOp[config.Admin](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
case config.ChatRPCChatCfgFileName:
op, err = compareAndOp[config.Chat](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
case config.ShareFileName:
op, err = compareAndOp[config.Share](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
case config.RedisConfigFileName:
op, err = compareAndOp[config.Redis](c, cm.config.Name2Config(cf.ConfigName), &cf, cm.client)
default:
apiresp.GinError(c, errs.ErrArgs.Wrap())
return
}
if err != nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
return
}
if op != nil {
ops = append(ops, op)
}
}
if len(ops) > 0 {
tx := cm.client.Txn(c)
if _, err = tx.Then(datautil.Batch(func(op *clientv3.Op) clientv3.Op { return *op }, ops)...).Commit(); err != nil {
apiresp.GinError(c, errs.WrapMsg(err, "save to etcd failed"))
return
}
}
apiresp.GinSuccess(c, nil)
}
func compareAndOp[T any](c *gin.Context, old any, req *apistruct.SetConfigReq, client *clientv3.Client) (*clientv3.Op, error) {
conf := new(T)
err := json.Unmarshal([]byte(req.Data), &conf)
if err != nil {
return nil, errs.ErrArgs.WithDetail(err.Error()).Wrap()
}
eq := reflect.DeepEqual(old, conf)
if eq {
return nil, nil
}
data, err := json.Marshal(conf)
if err != nil {
return nil, errs.ErrArgs.WithDetail(err.Error()).Wrap()
}
op := clientv3.OpPut(etcd.BuildKey(req.ConfigName), string(data))
return &op, nil
}
func compareAndSave[T any](c *gin.Context, old any, req *apistruct.SetConfigReq, client *clientv3.Client) error {
conf := new(T)
err := json.Unmarshal([]byte(req.Data), &conf)
if err != nil {
return errs.ErrArgs.WithDetail(err.Error()).Wrap()
}
eq := reflect.DeepEqual(old, conf)
if eq {
return nil
}
data, err := json.Marshal(conf)
if err != nil {
return errs.ErrArgs.WithDetail(err.Error()).Wrap()
}
_, err = client.Put(c, etcd.BuildKey(req.ConfigName), string(data))
if err != nil {
return errs.WrapMsg(err, "save to etcd failed")
}
return nil
}
func (cm *ConfigManager) ResetConfig(c *gin.Context) {
go func() {
if err := cm.resetConfig(c, true); err != nil {
log.ZError(c, "reset config err", err)
}
}()
apiresp.GinSuccess(c, nil)
}
func (cm *ConfigManager) resetConfig(c *gin.Context, checkChange bool, ops ...clientv3.Op) error {
txn := cm.client.Txn(c)
type initConf struct {
old any
new any
}
configMap := map[string]*initConf{
config.DiscoveryConfigFileName: {old: &cm.config.Discovery, new: new(config.Discovery)},
config.LogConfigFileName: {old: &cm.config.Log, new: new(config.Log)},
config.MongodbConfigFileName: {old: &cm.config.Mongo, new: new(config.Mongo)},
config.ChatAPIAdminCfgFileName: {old: &cm.config.AdminAPI, new: new(config.API)},
config.ChatAPIChatCfgFileName: {old: &cm.config.ChatAPI, new: new(config.API)},
config.ChatRPCAdminCfgFileName: {old: &cm.config.Admin, new: new(config.Admin)},
config.ChatRPCChatCfgFileName: {old: &cm.config.Chat, new: new(config.Chat)},
config.RedisConfigFileName: {old: &cm.config.Redis, new: new(config.Redis)},
config.ShareFileName: {old: &cm.config.Share, new: new(config.Share)},
}
changedKeys := make([]string, 0, len(configMap))
for k, v := range configMap {
err := config.Load(
cm.configPath,
k,
config.EnvPrefixMap[k],
cm.runtimeEnv,
v.new,
)
if err != nil {
log.ZError(c, "load config failed", err)
continue
}
equal := reflect.DeepEqual(v.old, v.new)
if !checkChange || !equal {
changedKeys = append(changedKeys, k)
}
}
for _, k := range changedKeys {
data, err := json.Marshal(configMap[k].new)
if err != nil {
log.ZError(c, "marshal config failed", err)
continue
}
ops = append(ops, clientv3.OpPut(etcd.BuildKey(k), string(data)))
}
if len(ops) > 0 {
txn.Then(ops...)
_, err := txn.Commit()
if err != nil {
return errs.WrapMsg(err, "commit etcd txn failed")
}
}
return nil
}
func (cm *ConfigManager) Restart(c *gin.Context) {
go cm.restart(c)
apiresp.GinSuccess(c, nil)
}
func (cm *ConfigManager) restart(c *gin.Context) {
time.Sleep(waitHttp) // wait for Restart http call return
t := time.Now().Unix()
_, err := cm.client.Put(c, etcd.BuildKey(etcd.RestartKey), strconv.Itoa(int(t)))
if err != nil {
log.ZError(c, "restart etcd put key failed", err)
}
}
func (cm *ConfigManager) SetEnableConfigManager(c *gin.Context) {
var req apistruct.SetEnableConfigManagerReq
if err := c.BindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
return
}
var enableStr string
if req.Enable {
enableStr = etcd.Enable
} else {
enableStr = etcd.Disable
}
resp, err := cm.client.Get(c, etcd.BuildKey(etcd.EnableConfigCenterKey))
if err != nil {
apiresp.GinError(c, errs.WrapMsg(err, "getEnableConfigManager failed"))
return
}
if !(resp.Count > 0 && string(resp.Kvs[0].Value) == etcd.Enable) && req.Enable {
go func() {
time.Sleep(waitHttp) // wait for Restart http call return
err := cm.resetConfig(c, false, clientv3.OpPut(etcd.BuildKey(etcd.EnableConfigCenterKey), enableStr))
if err != nil {
log.ZError(c, "writeAllConfig failed", err)
}
}()
} else {
_, err = cm.client.Put(c, etcd.BuildKey(etcd.EnableConfigCenterKey), enableStr)
if err != nil {
apiresp.GinError(c, errs.WrapMsg(err, "setEnableConfigManager failed"))
return
}
}
apiresp.GinSuccess(c, nil)
}
func (cm *ConfigManager) GetEnableConfigManager(c *gin.Context) {
resp, err := cm.client.Get(c, etcd.BuildKey(etcd.EnableConfigCenterKey))
if err != nil {
apiresp.GinError(c, errs.WrapMsg(err, "getEnableConfigManager failed"))
return
}
var enable bool
if resp.Count > 0 && string(resp.Kvs[0].Value) == etcd.Enable {
enable = true
}
apiresp.GinSuccess(c, &apistruct.GetEnableConfigManagerResp{Enable: enable})
}

314
internal/api/admin/start.go Normal file
View File

@@ -0,0 +1,314 @@
package admin
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
chatmw "git.imall.cloud/openim/chat/internal/api/mw"
"git.imall.cloud/openim/chat/internal/api/util"
"git.imall.cloud/openim/chat/pkg/common/config"
"git.imall.cloud/openim/chat/pkg/common/imapi"
"git.imall.cloud/openim/chat/pkg/common/kdisc"
disetcd "git.imall.cloud/openim/chat/pkg/common/kdisc/etcd"
adminclient "git.imall.cloud/openim/chat/pkg/protocol/admin"
chatclient "git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/gin-gonic/gin"
"github.com/openimsdk/tools/discovery"
"github.com/openimsdk/tools/discovery/etcd"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/mw"
"github.com/openimsdk/tools/system/program"
"github.com/openimsdk/tools/utils/datautil"
"github.com/openimsdk/tools/utils/runtimeenv"
clientv3 "go.etcd.io/etcd/client/v3"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type Config struct {
*config.AllConfig
RuntimeEnv string
ConfigPath string
}
func Start(ctx context.Context, index int, config *Config) error {
config.RuntimeEnv = runtimeenv.PrintRuntimeEnvironment()
if len(config.Share.ChatAdmin) == 0 {
return errs.New("share chat admin not configured")
}
apiPort, err := datautil.GetElemByIndex(config.AdminAPI.Api.Ports, index)
if err != nil {
return err
}
client, err := kdisc.NewDiscoveryRegister(&config.Discovery, config.RuntimeEnv, nil)
if err != nil {
return err
}
chatConn, err := client.GetConn(ctx, config.Discovery.RpcService.Chat, grpc.WithTransportCredentials(insecure.NewCredentials()), mw.GrpcClient())
if err != nil {
return err
}
adminConn, err := client.GetConn(ctx, config.Discovery.RpcService.Admin, grpc.WithTransportCredentials(insecure.NewCredentials()), mw.GrpcClient())
if err != nil {
return err
}
chatClient := chatclient.NewChatClient(chatConn)
adminClient := adminclient.NewAdminClient(adminConn)
im := imapi.New(config.Share.OpenIM.ApiURL, config.Share.OpenIM.Secret, config.Share.OpenIM.AdminUserID)
base := util.Api{
ImUserID: config.Share.OpenIM.AdminUserID,
ProxyHeader: config.Share.ProxyHeader,
ChatAdminUserID: config.Share.ChatAdmin[0],
}
adminApi := New(chatClient, adminClient, im, &base)
mwApi := chatmw.New(adminClient)
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.Recovery(), mw.CorsHandler(), mw.GinParseOperationID(), func(c *gin.Context) {
// 确保 operationID 被正确设置到 context 中
operationID := c.GetHeader("operationid")
if operationID != "" {
c.Set("operationID", operationID)
}
c.Next()
})
SetAdminRoute(engine, adminApi, mwApi, config, client)
if config.Discovery.Enable == kdisc.ETCDCONST {
cm := disetcd.NewConfigManager(client.(*etcd.SvcDiscoveryRegistryImpl).GetClient(), config.GetConfigNames())
cm.Watch(ctx)
}
var (
netDone = make(chan struct{}, 1)
netErr error
)
server := http.Server{Addr: fmt.Sprintf(":%d", apiPort), Handler: engine}
go func() {
err = server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
netErr = errs.WrapMsg(err, fmt.Sprintf("api start err: %s", server.Addr))
netDone <- struct{}{}
}
}()
shutdown := func() error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
return errs.WrapMsg(err, "shutdown err")
}
return nil
}
disetcd.RegisterShutDown(shutdown)
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM)
select {
case <-sigs:
program.SIGTERMExit()
if err := shutdown(); err != nil {
return err
}
case <-netDone:
close(netDone)
return netErr
}
return nil
}
func SetAdminRoute(router gin.IRouter, admin *Api, mw *chatmw.MW, cfg *Config, client discovery.SvcDiscoveryRegistry) {
adminRouterGroup := router.Group("/account")
adminRouterGroup.POST("/login", admin.AdminLogin) // Login
adminRouterGroup.POST("/update", mw.CheckAdmin, admin.AdminUpdateInfo) // Modify information
adminRouterGroup.POST("/info", mw.CheckAdmin, admin.AdminInfo) // Get information
adminRouterGroup.POST("/change_password", mw.CheckAdmin, admin.ChangeAdminPassword) // Change admin account's password
adminRouterGroup.POST("/change_operation_password", mw.CheckAdmin, admin.ChangeOperationPassword) // Change operation password
adminRouterGroup.POST("/set_google_auth_key", mw.CheckAdmin, admin.SetGoogleAuthKey) // Set Google Authenticator key
adminRouterGroup.POST("/add_admin", mw.CheckAdmin, admin.AddAdminAccount) // Add admin account
adminRouterGroup.POST("/add_user", mw.CheckAdmin, admin.AddUserAccount) // Add user account
adminRouterGroup.POST("/del_admin", mw.CheckAdmin, admin.DelAdminAccount) // Delete admin
adminRouterGroup.POST("/search", mw.CheckAdmin, admin.SearchAdminAccount) // Get admin list
adminRouterGroup.POST("/statistics", mw.CheckAdmin, admin.GetStatistics) // Get system statistics
//account.POST("/add_notification_account")
importGroup := router.Group("/user/import")
importGroup.POST("/json", mw.CheckAdmin, admin.ImportUserByJson)
importGroup.POST("/xlsx", mw.CheckAdmin, admin.ImportUserByXlsx)
importGroup.GET("/xlsx", admin.BatchImportTemplate)
allowRegisterGroup := router.Group("/user/allow_register", mw.CheckAdmin)
allowRegisterGroup.POST("/get", admin.GetAllowRegister)
allowRegisterGroup.POST("/set", admin.SetAllowRegister)
defaultRouter := router.Group("/default", mw.CheckAdmin)
defaultUserRouter := defaultRouter.Group("/user")
defaultUserRouter.POST("/add", admin.AddDefaultFriend) // Add default friend at registration
defaultUserRouter.POST("/del", admin.DelDefaultFriend) // Delete default friend at registration
defaultUserRouter.POST("/find", admin.FindDefaultFriend) // Default friend list
defaultUserRouter.POST("/search", admin.SearchDefaultFriend) // Search default friend list at registration
defaultGroupRouter := defaultRouter.Group("/group")
defaultGroupRouter.POST("/add", admin.AddDefaultGroup) // Add default group at registration
defaultGroupRouter.POST("/del", admin.DelDefaultGroup) // Delete default group at registration
defaultGroupRouter.POST("/find", admin.FindDefaultGroup) // Get default group list at registration
defaultGroupRouter.POST("/search", admin.SearchDefaultGroup) // Search default group list at registration
invitationCodeRouter := router.Group("/invitation_code", mw.CheckAdmin)
invitationCodeRouter.POST("/add", admin.AddInvitationCode) // Add invitation code
invitationCodeRouter.POST("/gen", admin.GenInvitationCode) // Generate invitation code
invitationCodeRouter.POST("/del", admin.DelInvitationCode) // Delete invitation code
invitationCodeRouter.POST("/search", admin.SearchInvitationCode) // Search invitation code
forbiddenRouter := router.Group("/forbidden", mw.CheckAdmin)
ipForbiddenRouter := forbiddenRouter.Group("/ip")
ipForbiddenRouter.POST("/add", admin.AddIPForbidden) // Add forbidden IP for registration/login
ipForbiddenRouter.POST("/del", admin.DelIPForbidden) // Delete forbidden IP for registration/login
ipForbiddenRouter.POST("/search", admin.SearchIPForbidden) // Search forbidden IPs for registration/login
userForbiddenRouter := forbiddenRouter.Group("/user")
userForbiddenRouter.POST("/add", admin.AddUserIPLimitLogin) // Add limit for user login on specific IP
userForbiddenRouter.POST("/del", admin.DelUserIPLimitLogin) // Delete user limit on specific IP for login
userForbiddenRouter.POST("/search", admin.SearchUserIPLimitLogin) // Search limit for user login on specific IP
appletRouterGroup := router.Group("/applet", mw.CheckAdmin)
appletRouterGroup.POST("/add", admin.AddApplet) // Add applet
appletRouterGroup.POST("/del", admin.DelApplet) // Delete applet
appletRouterGroup.POST("/update", admin.UpdateApplet) // Modify applet
appletRouterGroup.POST("/search", admin.SearchApplet) // Search applet
blockRouter := router.Group("/block", mw.CheckAdmin)
blockRouter.POST("/add", admin.BlockUser) // Block user
blockRouter.POST("/del", admin.UnblockUser) // Unblock user
blockRouter.POST("/search", admin.SearchBlockUser) // Search blocked users
userRouter := router.Group("/user", mw.CheckAdmin)
userRouter.POST("/password/reset", admin.ResetUserPassword) // Reset user password
initGroup := router.Group("/client_config", mw.CheckAdmin)
initGroup.POST("/get", admin.GetClientConfig) // Get client initialization configuration
initGroup.POST("/set", admin.SetClientConfig) // Set client initialization configuration
initGroup.POST("/del", admin.DelClientConfig) // Delete client initialization configuration
statistic := router.Group("/statistic", mw.CheckAdmin)
statistic.POST("/new_user_count", admin.NewUserCount)
statistic.POST("/login_user_count", admin.LoginUserCount)
statistic.POST("/online_user_count", admin.OnlineUserCount)
statistic.POST("/online_user_count_trend", admin.OnlineUserCountTrend)
statistic.POST("/user_send_msg_count", admin.UserSendMsgCount)
statistic.POST("/user_send_msg_count_trend", admin.UserSendMsgCountTrend)
statistic.POST("/user_send_msg_query", admin.UserSendMsgQuery)
applicationGroup := router.Group("application")
applicationGroup.POST("/add_version", mw.CheckAdmin, admin.AddApplicationVersion)
applicationGroup.POST("/update_version", mw.CheckAdmin, admin.UpdateApplicationVersion)
applicationGroup.POST("/delete_version", mw.CheckAdmin, admin.DeleteApplicationVersion)
applicationGroup.POST("/latest_version", admin.LatestApplicationVersion)
applicationGroup.POST("/page_versions", admin.PageApplicationVersion)
var etcdClient *clientv3.Client
if cfg.Discovery.Enable == kdisc.ETCDCONST {
etcdClient = client.(*etcd.SvcDiscoveryRegistryImpl).GetClient()
}
cm := NewConfigManager(cfg.AllConfig, etcdClient, cfg.ConfigPath, cfg.RuntimeEnv)
{
configGroup := router.Group("/config", mw.CheckAdmin)
configGroup.POST("/get_config_list", cm.GetConfigList)
configGroup.POST("/get_config", cm.GetConfig)
configGroup.POST("/set_config", cm.SetConfig)
configGroup.POST("/set_configs", cm.SetConfigs)
configGroup.POST("/reset_config", cm.ResetConfig)
configGroup.POST("/get_enable_config_manager", cm.GetEnableConfigManager)
configGroup.POST("/set_enable_config_manager", cm.SetEnableConfigManager)
}
{
router.POST("/restart", mw.CheckAdmin, cm.Restart)
}
// ==================== 敏感词管理路由 ====================
sensitiveWordRouter := router.Group("/sensitive_word") // 暂时注释掉 mw.CheckAdmin 用于测试
// 敏感词管理
sensitiveWordRouter.POST("/add", mw.CheckAdmin, admin.AddSensitiveWord) // 添加敏感词
sensitiveWordRouter.POST("/update", mw.CheckAdmin, admin.UpdateSensitiveWord) // 更新敏感词
sensitiveWordRouter.POST("/delete", mw.CheckAdmin, admin.DeleteSensitiveWord) // 删除敏感词
sensitiveWordRouter.POST("/get", mw.CheckAdmin, admin.GetSensitiveWord) // 获取敏感词
sensitiveWordRouter.POST("/search", mw.CheckAdmin, admin.SearchSensitiveWords) // 搜索敏感词
sensitiveWordRouter.POST("/batch_add", mw.CheckAdmin, admin.BatchAddSensitiveWords) // 批量添加敏感词
sensitiveWordRouter.POST("/batch_update", mw.CheckAdmin, admin.BatchUpdateSensitiveWords) // 批量更新敏感词
sensitiveWordRouter.POST("/batch_delete", mw.CheckAdmin, admin.BatchDeleteSensitiveWords) // 批量删除敏感词
// 敏感词分组管理
groupRouter := sensitiveWordRouter.Group("/group")
groupRouter.POST("/add", mw.CheckAdmin, admin.AddSensitiveWordGroup) // 添加敏感词分组
groupRouter.POST("/update", mw.CheckAdmin, admin.UpdateSensitiveWordGroup) // 更新敏感词分组
groupRouter.POST("/delete", mw.CheckAdmin, admin.DeleteSensitiveWordGroup) // 删除敏感词分组
groupRouter.POST("/get", mw.CheckAdmin, admin.GetSensitiveWordGroup) // 获取敏感词分组
groupRouter.POST("/list", mw.CheckAdmin, admin.GetAllSensitiveWordGroups) // 获取所有敏感词分组
// 敏感词配置管理
configRouter := sensitiveWordRouter.Group("/config")
configRouter.GET("/", mw.CheckAdmin, admin.GetSensitiveWordConfig) // 获取敏感词配置
configRouter.POST("/get", mw.CheckAdmin, admin.GetSensitiveWordConfig) // 获取敏感词配置
configRouter.POST("/update", mw.CheckAdmin, admin.UpdateSensitiveWordConfig) // 更新敏感词配置
// 敏感词日志管理
logRouter := sensitiveWordRouter.Group("/log")
logRouter.POST("/list", mw.CheckAdmin, admin.GetSensitiveWordLogs) // 获取敏感词日志
// 用户登录记录管理
loginRecordRouter := router.Group("/user_login_record", mw.CheckAdmin)
loginRecordRouter.POST("/list", admin.GetUserLoginRecords) // 查询用户登录记录
logRouter.POST("/delete", mw.CheckAdmin, admin.DeleteSensitiveWordLogs) // 删除敏感词日志
// 敏感词统计
statsRouter := sensitiveWordRouter.Group("/stats")
statsRouter.GET("/", mw.CheckAdmin, admin.GetSensitiveWordStats) // 获取敏感词统计
statsRouter.POST("/word_stats", mw.CheckAdmin, admin.GetSensitiveWordStats) // 获取敏感词统计
statsRouter.POST("/log_stats", mw.CheckAdmin, admin.GetSensitiveWordLogStats) // 获取敏感词日志统计
// ==================== 定时任务管理路由 ====================
scheduledTaskRouter := router.Group("/scheduled_task", mw.CheckAdmin)
scheduledTaskRouter.POST("/list", admin.GetScheduledTasks) // 获取定时任务列表
scheduledTaskRouter.POST("/delete", admin.DeleteScheduledTask) // 删除定时任务
// ==================== 系统配置管理路由 ====================
systemConfigRouter := router.Group("/system_config", mw.CheckAdmin)
systemConfigRouter.POST("/create", admin.CreateSystemConfig) // 创建系统配置
systemConfigRouter.POST("/get", admin.GetSystemConfig) // 获取系统配置详情
systemConfigRouter.POST("/list", admin.GetAllSystemConfigs) // 获取所有系统配置(分页)
systemConfigRouter.POST("/update", admin.UpdateSystemConfig) // 更新系统配置
systemConfigRouter.POST("/update_value", admin.UpdateSystemConfigValue) // 更新系统配置值
systemConfigRouter.POST("/update_enabled", admin.UpdateSystemConfigEnabled) // 更新系统配置启用状态
systemConfigRouter.POST("/delete", admin.DeleteSystemConfig) // 删除系统配置
systemConfigRouter.POST("/get_enabled", admin.GetEnabledSystemConfigs) // 获取所有已启用的配置
// ==================== 钱包管理路由 ====================
walletRouter := router.Group("/wallet", mw.CheckAdmin)
walletRouter.POST("/get", admin.GetUserWallet) // 获取用户钱包信息
walletRouter.POST("/get_wallets", admin.GetWallets) // 获取钱包列表
walletRouter.POST("/update_balance", admin.UpdateUserWalletBalance) // 更新用户余额(后台充值/扣款)
walletRouter.POST("/batch_update_balance", admin.BatchUpdateWalletBalance) // 批量更新用户余额(后台批量充值/扣款)
walletRouter.POST("/balance_records", admin.GetUserWalletBalanceRecords) // 获取用户余额变动记录列表
walletRouter.POST("/update_payment_password", admin.UpdateUserPaymentPassword) // 修改用户支付密码
walletRouter.POST("/set_withdraw_account", admin.SetUserWithdrawAccount) // 设置用户提款账号
// ==================== 提现管理路由(操作 withdraw_applications====================
withdrawRouter := router.Group("/withdraw", mw.CheckAdmin)
withdrawRouter.POST("/get", admin.GetWithdraw) // 获取提现申请详情
withdrawRouter.POST("/list", admin.GetWithdraws) // 获取提现申请列表(支持按状态筛选)
withdrawRouter.POST("/user_list", admin.GetUserWithdraws) // 获取用户的提现申请列表
withdrawRouter.POST("/audit", admin.AuditWithdraw) // 审核提现申请
// ==================== 实名认证审核路由 ====================
realNameAuthRouter := router.Group("/real_name_auth", mw.CheckAdmin)
realNameAuthRouter.POST("/list", admin.GetRealNameAuths) // 获取实名认证列表(支持按审核状态筛选)
realNameAuthRouter.POST("/audit", admin.AuditRealNameAuth) // 审核实名认证(通过/拒绝)
}

177
internal/api/bot/bot.go Normal file
View File

@@ -0,0 +1,177 @@
package bot
import (
"encoding/json"
"sort"
"strings"
"git.imall.cloud/openim/chat/internal/api/util"
"git.imall.cloud/openim/chat/pkg/botstruct"
"git.imall.cloud/openim/chat/pkg/common/imwebhook"
"git.imall.cloud/openim/chat/pkg/protocol/bot"
"git.imall.cloud/openim/protocol/constant"
"github.com/gin-gonic/gin"
"github.com/openimsdk/tools/a2r"
"github.com/openimsdk/tools/apiresp"
"github.com/openimsdk/tools/errs"
"golang.org/x/sync/errgroup"
)
func New(botClient bot.BotClient, api *util.Api) *Api {
return &Api{
Api: api,
botClient: botClient,
}
}
type Api struct {
*util.Api
botClient bot.BotClient
}
func (o *Api) CreateAgent(c *gin.Context) {
a2r.Call(c, bot.BotClient.CreateAgent, o.botClient)
}
func (o *Api) DeleteAgent(c *gin.Context) {
a2r.Call(c, bot.BotClient.DeleteAgent, o.botClient)
}
func (o *Api) UpdateAgent(c *gin.Context) {
a2r.Call(c, bot.BotClient.UpdateAgent, o.botClient)
}
func (o *Api) PageFindAgent(c *gin.Context) {
a2r.Call(c, bot.BotClient.PageFindAgent, o.botClient)
}
func (o *Api) AfterSendSingleMsg(c *gin.Context) {
var (
req = imwebhook.CallbackAfterSendSingleMsgReq{}
)
if err := c.BindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
return
}
if req.ContentType != constant.Text {
apiresp.GinSuccess(c, nil)
return
}
isAgent := botstruct.IsAgentUserID(req.RecvID)
if !isAgent {
apiresp.GinSuccess(c, nil)
return
}
var elem botstruct.TextElem
err := json.Unmarshal([]byte(req.Content), &elem)
if err != nil {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("json unmarshal error: "+err.Error()))
return
}
convID := getConversationIDByMsg(req.SessionType, req.SendID, req.RecvID, "")
key, ok := c.GetQuery(botstruct.Key)
if !ok {
apiresp.GinError(c, errs.ErrArgs.WithDetail("missing key in query").Wrap())
return
}
res, err := o.botClient.SendBotMessage(c, &bot.SendBotMessageReq{
AgentID: req.RecvID,
ConversationID: convID,
ContentType: req.ContentType,
Content: elem.Content,
Ex: req.Ex,
Key: key,
})
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, res)
}
func (o *Api) AfterSendGroupMsg(c *gin.Context) {
var (
req = imwebhook.CallbackAfterSendGroupMsgReq{}
)
if err := c.BindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap())
return
}
if req.ContentType != constant.AtText {
apiresp.GinSuccess(c, nil)
}
key, ok := c.GetQuery(botstruct.Key)
if !ok {
apiresp.GinError(c, errs.ErrArgs.WithDetail("missing key in query").Wrap())
return
}
var (
elem botstruct.AtElem
reqs []*bot.SendBotMessageReq
)
convID := getConversationIDByMsg(req.SessionType, req.SendID, "", req.GroupID)
err := json.Unmarshal([]byte(req.Content), &elem)
if err != nil {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("json unmarshal error: "+err.Error()))
}
for _, userID := range elem.AtUserList {
if botstruct.IsAgentUserID(userID) {
reqs = append(reqs, &bot.SendBotMessageReq{
AgentID: userID,
ConversationID: convID,
ContentType: req.ContentType,
Content: elem.Text,
Ex: req.Ex,
Key: key,
})
}
}
if len(reqs) == 0 {
apiresp.GinSuccess(c, nil)
}
g := errgroup.Group{}
g.SetLimit(min(len(reqs), 5))
for i := 0; i < len(reqs); i++ {
i := i
g.Go(func() error {
_, err := o.botClient.SendBotMessage(c, reqs[i])
if err != nil {
return err
}
return nil
})
}
err = g.Wait()
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, nil)
}
func getConversationIDByMsg(sessionType int32, sendID, recvID, groupID string) string {
switch sessionType {
case constant.SingleChatType:
l := []string{sendID, recvID}
sort.Strings(l)
return "si_" + strings.Join(l, "_") // single chat
case constant.WriteGroupChatType:
return "g_" + groupID // group chat
case constant.ReadGroupChatType:
return "sg_" + groupID // super group chat
case constant.NotificationChatType:
l := []string{sendID, recvID}
sort.Strings(l)
return "sn_" + strings.Join(l, "_")
}
return ""
}

139
internal/api/bot/start.go Normal file
View File

@@ -0,0 +1,139 @@
package bot
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
chatmw "git.imall.cloud/openim/chat/internal/api/mw"
"git.imall.cloud/openim/chat/internal/api/util"
"git.imall.cloud/openim/chat/pkg/common/config"
"git.imall.cloud/openim/chat/pkg/common/kdisc"
disetcd "git.imall.cloud/openim/chat/pkg/common/kdisc/etcd"
adminclient "git.imall.cloud/openim/chat/pkg/protocol/admin"
botclient "git.imall.cloud/openim/chat/pkg/protocol/bot"
"github.com/gin-gonic/gin"
"github.com/openimsdk/tools/discovery/etcd"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/mw"
"github.com/openimsdk/tools/system/program"
"github.com/openimsdk/tools/utils/datautil"
"github.com/openimsdk/tools/utils/runtimeenv"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type Config struct {
ApiConfig config.APIBot
Discovery config.Discovery
Share config.Share
Redis config.Redis
RuntimeEnv string
}
func Start(ctx context.Context, index int, cfg *Config) error {
cfg.RuntimeEnv = runtimeenv.PrintRuntimeEnvironment()
apiPort, err := datautil.GetElemByIndex(cfg.ApiConfig.Api.Ports, index)
if err != nil {
return err
}
client, err := kdisc.NewDiscoveryRegister(&cfg.Discovery, cfg.RuntimeEnv, nil)
if err != nil {
return err
}
botConn, err := client.GetConn(ctx, cfg.Discovery.RpcService.Bot, grpc.WithTransportCredentials(insecure.NewCredentials()), mw.GrpcClient())
if err != nil {
return err
}
adminConn, err := client.GetConn(ctx, cfg.Discovery.RpcService.Admin, grpc.WithTransportCredentials(insecure.NewCredentials()), mw.GrpcClient())
if err != nil {
return err
}
adminClient := adminclient.NewAdminClient(adminConn)
botClient := botclient.NewBotClient(botConn)
base := util.Api{
ImUserID: cfg.Share.OpenIM.AdminUserID,
ProxyHeader: cfg.Share.ProxyHeader,
ChatAdminUserID: cfg.Share.ChatAdmin[0],
}
botApi := New(botClient, &base)
mwApi := chatmw.New(adminClient)
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.Recovery(), mw.CorsHandler(), mw.GinParseOperationID(), func(c *gin.Context) {
// 确保 operationID 被正确设置到 context 中
operationID := c.GetHeader("operationid")
if operationID != "" {
c.Set("operationID", operationID)
}
c.Next()
})
SetBotRoute(engine, botApi, mwApi)
var (
netDone = make(chan struct{}, 1)
netErr error
)
server := http.Server{Addr: fmt.Sprintf(":%d", apiPort), Handler: engine}
go func() {
err = server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
netErr = errs.WrapMsg(err, fmt.Sprintf("api start err: %s", server.Addr))
netDone <- struct{}{}
}
}()
if cfg.Discovery.Enable == kdisc.ETCDCONST {
cm := disetcd.NewConfigManager(client.(*etcd.SvcDiscoveryRegistryImpl).GetClient(),
[]string{
config.ChatAPIBotCfgFileName,
config.DiscoveryConfigFileName,
config.ShareFileName,
config.LogConfigFileName,
},
)
cm.Watch(ctx)
}
shutdown := func() error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
return errs.WrapMsg(err, "shutdown err")
}
return nil
}
disetcd.RegisterShutDown(shutdown)
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM)
select {
case <-sigs:
program.SIGTERMExit()
if err := shutdown(); err != nil {
return err
}
case <-netDone:
close(netDone)
return netErr
}
return nil
}
func SetBotRoute(router gin.IRouter, bot *Api, mw *chatmw.MW) {
account := router.Group("/agent")
account.POST("/create", mw.CheckAdmin, bot.CreateAgent)
account.POST("/delete", mw.CheckAdmin, bot.DeleteAgent)
account.POST("/update", mw.CheckAdmin, bot.UpdateAgent)
account.POST("/page", mw.CheckToken, bot.PageFindAgent)
imwebhook := router.Group("/im_callback")
imwebhook.POST("/callbackAfterSendSingleMsgCommand", bot.AfterSendSingleMsg)
imwebhook.POST("/callbackAfterSendGroupMsgCommand", bot.AfterSendGroupMsg)
}

904
internal/api/chat/chat.go Normal file
View File

@@ -0,0 +1,904 @@
// 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"
"io"
"strings"
"time"
apiuserutil "git.imall.cloud/openim/chat/internal/api/util"
"strconv"
"git.imall.cloud/openim/chat/pkg/common/apistruct"
"git.imall.cloud/openim/chat/pkg/common/imapi"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/eerrs"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
chatpb "git.imall.cloud/openim/chat/pkg/protocol/chat"
constantpb "git.imall.cloud/openim/protocol/constant"
"git.imall.cloud/openim/protocol/sdkws"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/openimsdk/tools/a2r"
"github.com/openimsdk/tools/apiresp"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
)
func New(chatClient chatpb.ChatClient, adminClient admin.AdminClient, imApiCaller imapi.CallerInterface, api *apiuserutil.Api) *Api {
return &Api{
Api: api,
chatClient: chatClient,
adminClient: adminClient,
imApiCaller: imApiCaller,
}
}
type Api struct {
*apiuserutil.Api
chatClient chatpb.ChatClient
adminClient admin.AdminClient
imApiCaller imapi.CallerInterface
}
// operationIDKey 用于 context.WithValue 的自定义类型,避免使用字符串导致类型冲突
type operationIDKey struct{}
// ################## ACCOUNT ##################
func (o *Api) SendVerifyCode(c *gin.Context) {
req, err := a2r.ParseRequest[chatpb.SendVerifyCodeReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
ip, err := o.GetClientIP(c)
if err != nil {
apiresp.GinError(c, err)
return
}
req.Ip = ip
resp, err := o.chatClient.SendVerifyCode(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, resp)
}
func (o *Api) VerifyCode(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.VerifyCode, o.chatClient)
}
func (o *Api) GetCaptchaImage(c *gin.Context) {
// 获取或生成operationID图片请求可能没有operationID header
operationID := c.GetHeader("operationID")
if operationID == "" {
// 如果没有operationID生成一个UUID
operationID = uuid.New().String()
}
// 将operationID设置到context中确保RPC调用能获取到
// 使用自定义类型作为 key避免使用字符串导致类型冲突
ctx := context.WithValue(c.Request.Context(), operationIDKey{}, operationID)
// 调用RPC方法获取验证码6位数字
resp, err := o.chatClient.GetCaptchaImage(ctx, &chatpb.GetCaptchaImageReq{})
if err != nil {
apiresp.GinError(c, err)
return
}
// 在API层生成验证码图片
imgBytes, err := apiuserutil.GenerateCaptchaImageFromCode(resp.Code)
if err != nil {
apiresp.GinError(c, err)
return
}
// 验证图片数据
if len(imgBytes) == 0 {
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("generated captcha image is empty"))
return
}
// 检查GIF文件头
if len(imgBytes) < 6 {
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("generated captcha image data too short"))
return
}
// 设置响应头返回GIF图片
c.Header("Content-Type", "image/gif")
c.Header("Content-Length", fmt.Sprintf("%d", len(imgBytes)))
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
// 将验证码ID放在响应头中客户端可用于后续验证
c.Header("X-Captcha-ID", resp.CaptchaID)
// 返回图片数据
c.Data(200, "image/gif", imgBytes)
}
func (o *Api) RegisterUser(c *gin.Context) {
req, err := a2r.ParseRequest[chatpb.RegisterUserReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
ip, err := o.GetClientIP(c)
if err != nil {
apiresp.GinError(c, err)
return
}
req.Ip = ip
imToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
if err != nil {
apiresp.GinError(c, err)
return
}
apiCtx := mctx.WithApiToken(c, imToken)
rpcCtx := o.WithAdminUser(c)
checkResp, err := o.chatClient.CheckUserExist(rpcCtx, &chatpb.CheckUserExistReq{User: req.User})
if err != nil {
apiresp.GinError(c, err)
return
}
if checkResp.IsRegistered {
isUserNotExist, err := o.imApiCaller.AccountCheckSingle(apiCtx, checkResp.Userid)
if err != nil {
apiresp.GinError(c, err)
return
}
// if User is not exist in SDK server. You need delete this user and register new user again.
if isUserNotExist {
_, err := o.chatClient.DelUserAccount(rpcCtx, &chatpb.DelUserAccountReq{UserIDs: []string{checkResp.Userid}})
if err != nil {
apiresp.GinError(c, err)
return
}
}
}
// H5注册场景提供了registerToken默认自动登录
if req.RegisterToken != "" && !req.AutoLogin {
req.AutoLogin = true
}
respRegisterUser, err := o.chatClient.RegisterUser(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
userInfo := &sdkws.UserInfo{
UserID: respRegisterUser.UserID,
Nickname: req.User.Nickname,
FaceURL: req.User.FaceURL,
CreateTime: time.Now().UnixMilli(),
UserType: 0, // 设置默认用户类型
UserFlag: "",
}
err = o.imApiCaller.RegisterUser(apiCtx, []*sdkws.UserInfo{userInfo})
if err != nil {
apiresp.GinError(c, err)
return
}
if resp, err := o.adminClient.FindDefaultFriend(rpcCtx, &admin.FindDefaultFriendReq{}); err == nil {
_ = o.imApiCaller.ImportFriend(apiCtx, respRegisterUser.UserID, resp.UserIDs)
}
if resp, err := o.adminClient.FindDefaultGroup(rpcCtx, &admin.FindDefaultGroupReq{}); err == nil {
_ = o.imApiCaller.InviteToGroup(apiCtx, respRegisterUser.UserID, resp.GroupIDs)
}
var resp apistruct.UserRegisterResp
if req.AutoLogin {
resp.ImToken, err = o.imApiCaller.GetUserToken(apiCtx, respRegisterUser.UserID, req.Platform)
if err != nil {
apiresp.GinError(c, err)
return
}
}
resp.ChatToken = respRegisterUser.ChatToken
resp.UserID = respRegisterUser.UserID
apiresp.GinSuccess(c, &resp)
}
func (o *Api) Login(c *gin.Context) {
req, err := a2r.ParseRequest[chatpb.LoginReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
ip, err := o.GetClientIP(c)
if err != nil {
apiresp.GinError(c, err)
return
}
req.Ip = ip
resp, err := o.chatClient.Login(c, req)
if err != nil {
// 调试日志:打印错误的详细信息
errStr := err.Error()
log.ZError(c, "Login error detected", err,
"errorString", errStr,
"errorType", fmt.Sprintf("%T", err),
"contains20015", strings.Contains(errStr, "20015"),
"containsAccountBlocked", strings.Contains(errStr, "AccountBlocked"),
"contains封禁", strings.Contains(errStr, "账户已被封禁"))
// 检查是否是账户被封禁的错误,确保返回明确的错误提示
// 需要检查错误字符串中是否包含封禁相关的关键词,即使错误被包装了
if strings.Contains(errStr, "20015") || strings.Contains(errStr, "AccountBlocked") || strings.Contains(errStr, "账户已被封禁") {
log.ZInfo(c, "Detected AccountBlocked error, returning explicit error", "originalError", errStr)
apiresp.GinError(c, eerrs.ErrAccountBlocked.WrapMsg("账户已被封禁"))
return
}
// 如果错误被包装成 ServerInternalError尝试从错误链中提取原始错误
// 检查错误消息中是否包含封禁相关的堆栈信息
if strings.Contains(errStr, "ServerInternalError") && (strings.Contains(errStr, "20015") || strings.Contains(errStr, "AccountBlocked")) {
log.ZInfo(c, "Detected AccountBlocked error in wrapped ServerInternalError, returning explicit error", "originalError", errStr)
apiresp.GinError(c, eerrs.ErrAccountBlocked.WrapMsg("账户已被封禁"))
return
}
log.ZError(c, "Login error not AccountBlocked, returning original error", err)
apiresp.GinError(c, err)
return
}
adminToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
if err != nil {
apiresp.GinError(c, err)
return
}
apiCtx := mctx.WithApiToken(c, adminToken)
imToken, err := o.imApiCaller.GetUserToken(apiCtx, resp.UserID, req.Platform)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, &apistruct.LoginResp{
ImToken: imToken,
UserID: resp.UserID,
ChatToken: resp.ChatToken,
})
}
func (o *Api) ResetPassword(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.ResetPassword, o.chatClient)
}
func (o *Api) ChangePassword(c *gin.Context) {
req, err := a2r.ParseRequest[chatpb.ChangePasswordReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
resp, err := o.chatClient.ChangePassword(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
imToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
if err != nil {
apiresp.GinError(c, err)
return
}
err = o.imApiCaller.ForceOffLine(mctx.WithApiToken(c, imToken), req.UserID)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, resp)
}
// ################## USER ##################
func (o *Api) UpdateUserInfo(c *gin.Context) {
req, err := a2r.ParseRequest[chatpb.UpdateUserInfoReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
// 检查req是否为nil
if req == nil {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("request is nil"))
return
}
// 检查必要参数
if req.UserID == "" {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("userID is required"))
return
}
respUpdate, err := o.chatClient.UpdateUserInfo(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
var imToken string
imToken, err = o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
if err != nil {
apiresp.GinError(c, err)
return
}
// 检查token是否为空
if imToken == "" {
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to get admin token"))
return
}
// 检查imApiCaller是否为nil
if o.imApiCaller == nil {
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("imApiCaller is nil"))
return
}
var (
nickName string
faceURL string
userFlag string
)
if req.Nickname != nil {
nickName = req.Nickname.Value
} else {
nickName = respUpdate.NickName
}
if req.FaceURL != nil {
faceURL = req.FaceURL.Value
} else {
faceURL = respUpdate.FaceUrl
}
// 处理UserFlag字段
if req.UserFlag != nil {
userFlag = req.UserFlag.Value
}
// 确保字符串参数不为nil虽然这里应该是安全的但添加额外保护
if nickName == "" {
nickName = ""
}
if faceURL == "" {
faceURL = ""
}
err = o.imApiCaller.UpdateUserInfo(mctx.WithApiToken(c, imToken), req.UserID, nickName, faceURL, req.UserType, userFlag)
if err != nil {
apiresp.GinError(c, err)
return
}
// 构造 ex 字段的 JSON 数据(包含 userFlag 和 userType
type UserEx struct {
UserFlag string `json:"userFlag"`
UserType int32 `json:"userType"`
}
userEx := UserEx{
UserFlag: userFlag,
UserType: req.UserType,
}
exBytes, err := json.Marshal(userEx)
if err != nil {
apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to marshal user ex"))
return
}
// 调用 UpdateUserInfoEx 更新 ex 字段
err = o.imApiCaller.UpdateUserInfoEx(mctx.WithApiToken(c, imToken), req.UserID, string(exBytes))
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, apistruct.UpdateUserInfoResp{})
}
func (o *Api) FindUserPublicInfo(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.FindUserPublicInfo, o.chatClient)
}
func (o *Api) FindUserFullInfo(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.FindUserFullInfo, o.chatClient)
}
func (o *Api) SearchUserFullInfo(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.SearchUserFullInfo, o.chatClient)
}
func (o *Api) SearchUserPublicInfo(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.SearchUserPublicInfo, o.chatClient)
}
func (o *Api) GetTokenForVideoMeeting(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.GetTokenForVideoMeeting, o.chatClient)
}
// ################## APPLET ##################
func (o *Api) FindApplet(c *gin.Context) {
a2r.Call(c, admin.AdminClient.FindApplet, o.adminClient)
}
// ################## CONFIG ##################
func (o *Api) GetClientConfig(c *gin.Context) {
a2r.Call(c, admin.AdminClient.GetClientConfig, o.adminClient)
}
// ################## CALLBACK ##################
func (o *Api) OpenIMCallback(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
apiresp.GinError(c, err)
return
}
req := &chatpb.OpenIMCallbackReq{
Command: c.Query(constantpb.CallbackCommand),
Body: string(body),
}
if _, err := o.chatClient.OpenIMCallback(c, req); err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, nil)
}
func (o *Api) SearchFriend(c *gin.Context) {
req, err := a2r.ParseRequest[struct {
UserID string `json:"userID"`
chatpb.SearchUserInfoReq
}](c)
if err != nil {
apiresp.GinError(c, err)
return
}
if req.UserID == "" {
req.UserID = mctx.GetOpUserID(c)
}
imToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
if err != nil {
apiresp.GinError(c, err)
return
}
userIDs, err := o.imApiCaller.FriendUserIDs(mctx.WithApiToken(c, imToken), req.UserID)
if err != nil {
apiresp.GinError(c, err)
return
}
if len(userIDs) == 0 {
apiresp.GinSuccess(c, &chatpb.SearchUserInfoResp{})
return
}
req.SearchUserInfoReq.UserIDs = userIDs
resp, err := o.chatClient.SearchUserInfo(c, &req.SearchUserInfoReq)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, resp)
}
func (o *Api) AddFriend(c *gin.Context) {
req, err := a2r.ParseRequest[chatpb.AddFriendReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
// 获取当前用户ID发起添加好友的用户
ownerUserID := mctx.GetOpUserID(c)
// 获取用户信息,检查 userType
userInfoResp, err := o.chatClient.FindUserFullInfo(c, &chatpb.FindUserFullInfoReq{UserIDs: []string{ownerUserID}})
if err != nil {
apiresp.GinError(c, err)
return
}
if len(userInfoResp.Users) == 0 || userInfoResp.Users[0].UserType != 1 {
apiresp.GinError(c, errs.ErrNoPermission.WrapMsg("only userType=1 users can add friends directly"))
return
}
// 调用 chat-deploy 的 ImportFriends
imToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
if err != nil {
apiresp.GinError(c, err)
return
}
// 调用 ImportFriend传入要添加的好友ID
err = o.imApiCaller.ImportFriend(mctx.WithApiToken(c, imToken), ownerUserID, []string{req.UserID})
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, nil)
}
func (o *Api) LatestApplicationVersion(c *gin.Context) {
a2r.Call(c, admin.AdminClient.LatestApplicationVersion, o.adminClient)
}
func (o *Api) PageApplicationVersion(c *gin.Context) {
a2r.Call(c, admin.AdminClient.PageApplicationVersion, o.adminClient)
}
// ==================== 敏感词检测相关 API ====================
// GetSensitiveWords 获取敏感词列表
func (o *Api) GetSensitiveWords(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.GetSensitiveWords, o.chatClient)
}
// CheckSensitiveWords 检测敏感词
func (o *Api) CheckSensitiveWords(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.CheckSensitiveWords, o.chatClient)
}
// ==================== 收藏相关 API ====================
// CreateFavorite 创建收藏
func (o *Api) CreateFavorite(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.CreateFavorite, o.chatClient)
}
// GetFavorite 获取收藏详情
func (o *Api) GetFavorite(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.GetFavorite, o.chatClient)
}
// GetFavorites 获取收藏列表
func (o *Api) GetFavorites(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.GetFavorites, o.chatClient)
}
// SearchFavorites 搜索收藏
func (o *Api) SearchFavorites(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.SearchFavorites, o.chatClient)
}
// UpdateFavorite 更新收藏
func (o *Api) UpdateFavorite(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.UpdateFavorite, o.chatClient)
}
// DeleteFavorite 删除收藏
func (o *Api) DeleteFavorite(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.DeleteFavorite, o.chatClient)
}
// GetFavoritesByTags 根据标签获取收藏
func (o *Api) GetFavoritesByTags(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.GetFavoritesByTags, o.chatClient)
}
// GetFavoriteCount 获取收藏数量
func (o *Api) GetFavoriteCount(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.GetFavoriteCount, o.chatClient)
}
// ==================== 定时任务相关 API ====================
// CreateScheduledTask 创建定时任务
func (o *Api) CreateScheduledTask(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.CreateScheduledTask, o.chatClient)
}
// GetScheduledTask 获取定时任务详情
func (o *Api) GetScheduledTask(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.GetScheduledTask, o.chatClient)
}
// GetScheduledTasks 获取定时任务列表
func (o *Api) GetScheduledTasks(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.GetScheduledTasks, o.chatClient)
}
// UpdateScheduledTask 更新定时任务
func (o *Api) UpdateScheduledTask(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.UpdateScheduledTask, o.chatClient)
}
// DeleteScheduledTask 删除定时任务
func (o *Api) DeleteScheduledTask(c *gin.Context) {
a2r.Call(c, chatpb.ChatClient.DeleteScheduledTask, o.chatClient)
}
// ==================== 系统配置相关 API ====================
// GetAppSystemConfigs 获取APP端配置返回所有 show_in_app=true 且 enabled=true 的配置)
func (o *Api) GetAppSystemConfigs(c *gin.Context) {
resp, err := o.chatClient.GetAppSystemConfigs(c, &chatpb.GetAppSystemConfigsReq{})
if err != nil {
apiresp.GinError(c, err)
return
}
// 转换为对象格式key 作为对象的键value 作为值
configMap := make(map[string]interface{})
for _, config := range resp.Configs {
convertedValue := o.convertValueFromString(config.Value, config.ValueType)
configMap[config.Key] = convertedValue
}
apiresp.GinSuccess(c, configMap)
}
// convertValueFromString 将字符串值转换为对应类型(用于返回给前端)
func (o *Api) convertValueFromString(value string, valueType int32) interface{} {
switch valueType {
case 1: // String
return value
case 2: // Number
// 尝试解析为数字
if num, err := strconv.ParseFloat(value, 64); err == nil {
// 如果是整数,返回整数;否则返回浮点数
if num == float64(int64(num)) {
return int64(num)
}
return num
}
return value
case 3: // Bool
if b, err := strconv.ParseBool(value); err == nil {
return b
}
return value
case 4: // JSON
var js interface{}
if err := json.Unmarshal([]byte(value), &js); err == nil {
return js
}
return value
default:
return value
}
}
// ==================== 钱包相关 API ====================
// GetWalletBalance 获取钱包余额
func (o *Api) GetWalletBalance(c *gin.Context) {
resp, err := o.chatClient.GetWalletBalance(c, &chatpb.GetWalletBalanceReq{})
if err != nil {
apiresp.GinError(c, err)
return
}
// 将余额从分转换为元(前端显示用)
apiresp.GinSuccess(c, map[string]interface{}{
"balance": resp.Balance, // 余额(单位:分)
})
}
// GetWalletInfo 获取钱包详细信息
func (o *Api) GetWalletInfo(c *gin.Context) {
resp, err := o.chatClient.GetWalletInfo(c, &chatpb.GetWalletInfoReq{})
if err != nil {
apiresp.GinError(c, err)
return
}
// 构建响应
result := map[string]interface{}{
"balance": resp.Balance, // 余额(单位:分)
"withdrawAccount": resp.WithdrawAccount,
"withdrawAccountType": resp.WithdrawAccountType,
"withdrawReceiveAccount": resp.WithdrawReceiveAccount,
"hasPaymentPassword": resp.HasPaymentPassword,
}
// 添加实名认证信息(如果存在)
if resp.RealNameAuth != nil {
result["realNameAuth"] = map[string]interface{}{
"idCard": resp.RealNameAuth.IdCard,
"idCardPhotoFront": resp.RealNameAuth.IdCardPhotoFront,
"idCardPhotoBack": resp.RealNameAuth.IdCardPhotoBack,
"name": resp.RealNameAuth.Name,
"auditStatus": resp.RealNameAuth.AuditStatus,
}
}
apiresp.GinSuccess(c, result)
}
// GetWalletBalanceRecords 获取余额明细
func (o *Api) GetWalletBalanceRecords(c *gin.Context) {
var req chatpb.GetWalletBalanceRecordsReq
if err := c.ShouldBindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("invalid request"))
return
}
resp, err := o.chatClient.GetWalletBalanceRecords(c, &req)
if err != nil {
apiresp.GinError(c, err)
return
}
// 转换为响应格式
records := make([]map[string]interface{}, 0, len(resp.Records))
for _, record := range resp.Records {
records = append(records, map[string]interface{}{
"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,
})
}
apiresp.GinSuccess(c, map[string]interface{}{
"total": resp.Total,
"records": records,
})
}
// SetPaymentPassword 设置支付密码
func (o *Api) SetPaymentPassword(c *gin.Context) {
var req chatpb.SetPaymentPasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("invalid request"))
return
}
resp, err := o.chatClient.SetPaymentPassword(c, &req)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, resp)
}
// SetWithdrawAccount 设置提现账号
func (o *Api) SetWithdrawAccount(c *gin.Context) {
var req chatpb.SetWithdrawAccountReq
if err := c.ShouldBindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("invalid request"))
return
}
resp, err := o.chatClient.SetWithdrawAccount(c, &req)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, resp)
}
// CreateWithdrawApplication 申请提现
func (o *Api) CreateWithdrawApplication(c *gin.Context) {
req, err := a2r.ParseRequest[chatpb.CreateWithdrawApplicationReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
// 从请求中获取客户端IP
ip, err := o.GetClientIP(c)
if err != nil {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("无法获取客户端IP"))
return
}
req.Ip = ip
resp, err := o.chatClient.CreateWithdrawApplication(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, map[string]interface{}{
"applicationID": resp.ApplicationID,
})
}
// GetWithdrawApplications 获取提现申请列表
func (o *Api) GetWithdrawApplications(c *gin.Context) {
var req chatpb.GetWithdrawApplicationsReq
if err := c.ShouldBindJSON(&req); err != nil {
apiresp.GinError(c, errs.ErrArgs.WrapMsg("invalid request"))
return
}
resp, err := o.chatClient.GetWithdrawApplications(c, &req)
if err != nil {
apiresp.GinError(c, err)
return
}
// 转换为响应格式
applications := make([]map[string]interface{}, 0, len(resp.Applications))
for _, app := range resp.Applications {
applications = append(applications, map[string]interface{}{
"id": app.Id,
"userID": app.UserID,
"amount": app.Amount,
"withdrawAccount": app.WithdrawAccount,
"withdrawAccountType": app.WithdrawAccountType,
"status": app.Status,
"auditorID": app.AuditorID,
"auditTime": app.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,
"updateTime": app.UpdateTime,
})
}
apiresp.GinSuccess(c, map[string]interface{}{
"total": resp.Total,
"applications": applications,
})
}
// RealNameAuth 实名认证
func (o *Api) RealNameAuth(c *gin.Context) {
req, err := a2r.ParseRequest[chatpb.RealNameAuthReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
resp, err := o.chatClient.RealNameAuth(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, map[string]interface{}{
"success": resp.Success,
"message": resp.Message,
"idCardPhotoFront": resp.IdCardPhotoFront,
"idCardPhotoBack": resp.IdCardPhotoBack,
})
}

197
internal/api/chat/start.go Normal file
View File

@@ -0,0 +1,197 @@
package chat
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
chatmw "git.imall.cloud/openim/chat/internal/api/mw"
"git.imall.cloud/openim/chat/internal/api/util"
"git.imall.cloud/openim/chat/pkg/common/config"
"git.imall.cloud/openim/chat/pkg/common/imapi"
"git.imall.cloud/openim/chat/pkg/common/kdisc"
disetcd "git.imall.cloud/openim/chat/pkg/common/kdisc/etcd"
adminclient "git.imall.cloud/openim/chat/pkg/protocol/admin"
chatclient "git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/gin-gonic/gin"
"github.com/openimsdk/tools/discovery/etcd"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/mw"
"github.com/openimsdk/tools/system/program"
"github.com/openimsdk/tools/utils/datautil"
"github.com/openimsdk/tools/utils/runtimeenv"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type Config struct {
ApiConfig config.API
Discovery config.Discovery
Share config.Share
Redis config.Redis
RuntimeEnv string
}
func Start(ctx context.Context, index int, cfg *Config) error {
cfg.RuntimeEnv = runtimeenv.PrintRuntimeEnvironment()
if len(cfg.Share.ChatAdmin) == 0 {
return errs.New("share chat admin not configured")
}
apiPort, err := datautil.GetElemByIndex(cfg.ApiConfig.Api.Ports, index)
if err != nil {
return err
}
client, err := kdisc.NewDiscoveryRegister(&cfg.Discovery, cfg.RuntimeEnv, nil)
if err != nil {
return err
}
chatConn, err := client.GetConn(ctx, cfg.Discovery.RpcService.Chat, grpc.WithTransportCredentials(insecure.NewCredentials()), mw.GrpcClient())
if err != nil {
return err
}
adminConn, err := client.GetConn(ctx, cfg.Discovery.RpcService.Admin, grpc.WithTransportCredentials(insecure.NewCredentials()), mw.GrpcClient())
if err != nil {
return err
}
chatClient := chatclient.NewChatClient(chatConn)
adminClient := adminclient.NewAdminClient(adminConn)
im := imapi.New(cfg.Share.OpenIM.ApiURL, cfg.Share.OpenIM.Secret, cfg.Share.OpenIM.AdminUserID)
base := util.Api{
ImUserID: cfg.Share.OpenIM.AdminUserID,
ProxyHeader: cfg.Share.ProxyHeader,
ChatAdminUserID: cfg.Share.ChatAdmin[0],
}
adminApi := New(chatClient, adminClient, im, &base)
mwApi := chatmw.New(adminClient)
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.Recovery(), mw.CorsHandler(), mw.GinParseOperationID())
SetChatRoute(engine, adminApi, mwApi)
var (
netDone = make(chan struct{}, 1)
netErr error
)
server := http.Server{Addr: fmt.Sprintf(":%d", apiPort), Handler: engine}
go func() {
err = server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
netErr = errs.WrapMsg(err, fmt.Sprintf("api start err: %s", server.Addr))
netDone <- struct{}{}
}
}()
if cfg.Discovery.Enable == kdisc.ETCDCONST {
cm := disetcd.NewConfigManager(client.(*etcd.SvcDiscoveryRegistryImpl).GetClient(),
[]string{
config.ChatAPIChatCfgFileName,
config.DiscoveryConfigFileName,
config.ShareFileName,
config.LogConfigFileName,
},
)
cm.Watch(ctx)
}
shutdown := func() error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
return errs.WrapMsg(err, "shutdown err")
}
return nil
}
disetcd.RegisterShutDown(shutdown)
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM)
select {
case <-sigs:
program.SIGTERMExit()
if err := shutdown(); err != nil {
return err
}
case <-netDone:
close(netDone)
return netErr
}
return nil
}
func SetChatRoute(router gin.IRouter, chat *Api, mw *chatmw.MW) {
account := router.Group("/account")
account.GET("/captcha", chat.GetCaptchaImage) // Get captcha image
account.POST("/code/send", chat.SendVerifyCode) // Send verification code
account.POST("/code/verify", chat.VerifyCode) // Verify the verification code
account.POST("/register", mw.CheckAdminOrNil, chat.RegisterUser) // Register
account.POST("/login", chat.Login) // Login
account.POST("/password/reset", chat.ResetPassword) // Forgot password
account.POST("/password/change", mw.CheckToken, chat.ChangePassword) // Change password
user := router.Group("/user", mw.CheckToken)
user.POST("/update", chat.UpdateUserInfo) // Edit personal information
user.POST("/find/public", chat.FindUserPublicInfo) // Get user's public information
user.POST("/find/full", chat.FindUserFullInfo) // Get all information of the user
user.POST("/search/full", chat.SearchUserFullInfo) // Search user's public information
user.POST("/search/public", chat.SearchUserPublicInfo) // Search all information of the user
user.POST("/rtc/get_token", chat.GetTokenForVideoMeeting) // Get token for video meeting for the user
router.POST("/friend/search", mw.CheckToken, chat.SearchFriend)
router.POST("/friend/add", mw.CheckToken, chat.AddFriend)
router.Group("/applet").POST("/find", mw.CheckToken, chat.FindApplet) // Applet list
router.Group("/client_config").POST("/get", chat.GetClientConfig) // Get client initialization configuration
applicationGroup := router.Group("application")
applicationGroup.POST("/latest_version", chat.LatestApplicationVersion)
applicationGroup.POST("/page_versions", chat.PageApplicationVersion)
router.Group("/callback").POST("/open_im", chat.OpenIMCallback) // Callback
// 系统配置相关接口(客户端)
systemConfig := router.Group("/system_config")
systemConfig.POST("/get_app_configs", chat.GetAppSystemConfigs) // 获取APP端配置show_in_app=true 且 enabled=true
// 钱包相关接口(客户端)
wallet := router.Group("/wallet", mw.CheckToken)
wallet.POST("/balance", chat.GetWalletBalance) // 获取钱包余额
wallet.POST("/info", chat.GetWalletInfo) // 获取钱包详细信息
wallet.POST("/balance_records", chat.GetWalletBalanceRecords) // 获取余额明细
wallet.POST("/payment_password/set", chat.SetPaymentPassword) // 设置支付密码(首次设置或修改)
wallet.POST("/withdraw_account/set", chat.SetWithdrawAccount) // 设置提现账号
wallet.POST("/withdraw/apply", chat.CreateWithdrawApplication) // 申请提现
wallet.POST("/withdraw/list", chat.GetWithdrawApplications) // 获取提现申请列表
wallet.POST("/real_name_auth", chat.RealNameAuth) // 实名认证
// 敏感词相关接口(客户端)
sensitive := router.Group("/sensitive_word", mw.CheckToken)
sensitive.POST("/get", chat.GetSensitiveWords) // 获取敏感词列表
sensitive.POST("/check", chat.CheckSensitiveWords) // 检测敏感词
// 收藏相关接口
favorite := router.Group("/favorite", mw.CheckToken)
favorite.POST("/create", chat.CreateFavorite) // 创建收藏
favorite.POST("/get", chat.GetFavorite) // 获取收藏详情
favorite.POST("/list", chat.GetFavorites) // 获取收藏列表
favorite.POST("/search", chat.SearchFavorites) // 搜索收藏
favorite.POST("/update", chat.UpdateFavorite) // 更新收藏
favorite.POST("/delete", chat.DeleteFavorite) // 删除收藏
favorite.POST("/tags", chat.GetFavoritesByTags) // 根据标签获取收藏
favorite.POST("/count", chat.GetFavoriteCount) // 获取收藏数量
// 定时任务相关接口
scheduledTask := router.Group("/scheduled_task", mw.CheckToken)
scheduledTask.POST("/create", chat.CreateScheduledTask) // 创建定时任务
scheduledTask.POST("/get", chat.GetScheduledTask) // 获取定时任务详情
scheduledTask.POST("/list", chat.GetScheduledTasks) // 获取定时任务列表
scheduledTask.POST("/update", chat.UpdateScheduledTask) // 更新定时任务
scheduledTask.POST("/delete", chat.DeleteScheduledTask) // 删除定时任务
}

145
internal/api/mw/mw.go Normal file
View File

@@ -0,0 +1,145 @@
// 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 mw
import (
"strconv"
"git.imall.cloud/openim/chat/pkg/common/constant"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
constantpb "git.imall.cloud/openim/protocol/constant"
"github.com/gin-gonic/gin"
"github.com/openimsdk/tools/apiresp"
"github.com/openimsdk/tools/errs"
)
func New(client admin.AdminClient) *MW {
return &MW{client: client}
}
type MW struct {
client admin.AdminClient
}
func (o *MW) parseToken(c *gin.Context) (string, int32, string, error) {
token := c.GetHeader("token")
if token == "" {
return "", 0, "", errs.ErrArgs.WrapMsg("token is empty")
}
resp, err := o.client.ParseToken(c, &admin.ParseTokenReq{Token: token})
if err != nil {
return "", 0, "", err
}
return resp.UserID, resp.UserType, token, nil
}
func (o *MW) parseTokenType(c *gin.Context, userType int32) (string, string, error) {
userID, t, token, err := o.parseToken(c)
if err != nil {
return "", "", err
}
if t != userType {
return "", "", errs.ErrArgs.WrapMsg("token type error")
}
return userID, token, nil
}
func (o *MW) isValidToken(c *gin.Context, userID string, token string) error {
resp, err := o.client.GetUserToken(c, &admin.GetUserTokenReq{UserID: userID})
if err != nil {
return err
}
if len(resp.TokensMap) == 0 {
return errs.ErrTokenExpired.Wrap()
}
if v, ok := resp.TokensMap[token]; ok {
switch v {
case constantpb.NormalToken:
case constantpb.KickedToken:
return errs.ErrTokenExpired.Wrap()
default:
return errs.ErrTokenUnknown.Wrap()
}
} else {
return errs.ErrTokenExpired.Wrap()
}
return nil
}
func (o *MW) setToken(c *gin.Context, userID string, userType int32) {
SetToken(c, userID, userType)
}
func (o *MW) CheckToken(c *gin.Context) {
userID, userType, token, err := o.parseToken(c)
if err != nil {
c.Abort()
apiresp.GinError(c, err)
return
}
if err := o.isValidToken(c, userID, token); err != nil {
c.Abort()
apiresp.GinError(c, err)
return
}
o.setToken(c, userID, userType)
}
func (o *MW) CheckAdmin(c *gin.Context) {
userID, token, err := o.parseTokenType(c, constant.AdminUser)
if err != nil {
c.Abort()
apiresp.GinError(c, err)
return
}
if err := o.isValidToken(c, userID, token); err != nil {
c.Abort()
apiresp.GinError(c, err)
return
}
o.setToken(c, userID, constant.AdminUser)
}
func (o *MW) CheckUser(c *gin.Context) {
userID, token, err := o.parseTokenType(c, constant.NormalUser)
if err != nil {
c.Abort()
apiresp.GinError(c, err)
return
}
if err := o.isValidToken(c, userID, token); err != nil {
c.Abort()
apiresp.GinError(c, err)
return
}
o.setToken(c, userID, constant.NormalUser)
}
func (o *MW) CheckAdminOrNil(c *gin.Context) {
defer c.Next()
userID, userType, _, err := o.parseToken(c)
if err != nil {
return
}
if userType == constant.AdminUser {
o.setToken(c, userID, constant.AdminUser)
}
}
func SetToken(c *gin.Context, userID string, userType int32) {
c.Set(constant.RpcOpUserID, userID)
c.Set(constant.RpcOpUserType, []string{strconv.Itoa(int(userType))})
c.Set(constant.RpcCustomHeader, []string{constant.RpcOpUserType})
}

292
internal/api/util/api.go Normal file
View File

@@ -0,0 +1,292 @@
package util
import (
"context"
"fmt"
"net"
"strings"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"github.com/gin-gonic/gin"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
)
type Api struct {
ImUserID string
ProxyHeader string
ChatAdminUserID string
}
func (o *Api) WithAdminUser(ctx context.Context) context.Context {
return mctx.WithAdminUser(ctx, o.ChatAdminUserID)
}
// isPrivateIP 检查IP是否是内网IP
func isPrivateIP(ip net.IP) bool {
if ip == nil {
return false
}
// 检查是否是内网IP范围
// 10.0.0.0/8
// 172.16.0.0/12
// 192.168.0.0/16
// 127.0.0.0/8 (localhost)
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
ip4 := ip.To4()
if ip4 == nil {
return false
}
// 10.0.0.0/8
if ip4[0] == 10 {
return true
}
// 172.16.0.0/12
if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 {
return true
}
// 192.168.0.0/16
if ip4[0] == 192 && ip4[1] == 168 {
return true
}
return false
}
// parseForwardedHeader 解析标准的 Forwarded 头RFC 7239
// 格式Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43
func parseForwardedHeader(forwarded string) string {
// 查找 for= 后面的IP地址
parts := strings.Split(forwarded, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToLower(part), "for=") {
ip := strings.TrimPrefix(part, "for=")
ip = strings.TrimPrefix(ip, "For=")
ip = strings.TrimPrefix(ip, "FOR=")
// 移除可能的引号和端口号
ip = strings.Trim(ip, `"`)
if idx := strings.Index(ip, ":"); idx != -1 {
ip = ip[:idx]
}
if parsedIP := net.ParseIP(ip); parsedIP != nil {
return ip
}
}
}
return ""
}
// GetClientIP 获取客户端真实IP地址
// 支持多层反向代理和CDN场景
// 优先级:
// 1. CDN特定头CF-Connecting-IP, True-Client-IP等- 最可靠
// 2. 标准 Forwarded 头RFC 7239- 现代标准
// 3. X-Real-IP - 通常由第一层代理设置比X-Forwarded-For更可靠
// 4. X-Forwarded-For - 取最左边的IP客户端真实IP格式client_ip, proxy1_ip, proxy2_ip, ...
// 5. 其他常见头X-Client-IP, True-Client-IP等
// 6. 自定义ProxyHeader
// 7. RemoteAddr - 最后回退
func (o *Api) GetClientIP(c *gin.Context) (string, error) {
// 记录所有相关的HTTP头用于调试
headers := map[string]string{
"Forwarded": c.Request.Header.Get("Forwarded"), // RFC 7239 标准头
"X-Forwarded-For": c.Request.Header.Get("X-Forwarded-For"),
"X-Real-IP": c.Request.Header.Get("X-Real-IP"),
"CF-Connecting-IP": c.Request.Header.Get("CF-Connecting-IP"), // Cloudflare - 真实客户端IP
"CF-Ray": c.Request.Header.Get("CF-Ray"), // Cloudflare - 请求ID用于验证来自CF
"True-Client-IP": c.Request.Header.Get("True-Client-IP"), // Cloudflare Enterprise, Akamai
"X-Client-IP": c.Request.Header.Get("X-Client-IP"), // 一些代理
"X-Forwarded": c.Request.Header.Get("X-Forwarded"), // 一些代理
"Forwarded-For": c.Request.Header.Get("Forwarded-For"), // 非标准,但有些代理使用
"X-Cluster-Client-IP": c.Request.Header.Get("X-Cluster-Client-IP"), // Kubernetes
"RemoteAddr": c.Request.RemoteAddr,
}
if o.ProxyHeader != "" {
headers["Custom-"+o.ProxyHeader] = c.Request.Header.Get(o.ProxyHeader)
}
// 打印所有能获取到的IP相关信息用于调试
customProxyHeader := ""
if o.ProxyHeader != "" {
customProxyHeader = headers["Custom-"+o.ProxyHeader]
}
log.ZInfo(c, "GetClientIP 调试信息 - 所有HTTP头",
"Forwarded", headers["Forwarded"],
"X-Forwarded-For", headers["X-Forwarded-For"],
"X-Real-IP", headers["X-Real-IP"],
"CF-Connecting-IP", headers["CF-Connecting-IP"],
"CF-Ray", headers["CF-Ray"],
"True-Client-IP", headers["True-Client-IP"],
"X-Client-IP", headers["X-Client-IP"],
"X-Forwarded", headers["X-Forwarded"],
"Forwarded-For", headers["Forwarded-For"],
"X-Cluster-Client-IP", headers["X-Cluster-Client-IP"],
"RemoteAddr", headers["RemoteAddr"],
"Custom-ProxyHeader", customProxyHeader)
// 1. 优先检查 Cloudflare 的 CF-Connecting-IP最可靠Cloudflare 直接设置真实客户端IP
// 检查是否来自 Cloudflare通过 CF-Ray 头判断)
cfRay := c.Request.Header.Get("CF-Ray")
cfConnectingIP := c.Request.Header.Get("CF-Connecting-IP")
if cfConnectingIP != "" {
// 特别记录 Cloudflare 相关信息
log.ZInfo(c, "GetClientIP detected Cloudflare request",
"CF-Connecting-IP", cfConnectingIP,
"CF-Ray", cfRay)
ip := strings.TrimSpace(cfConnectingIP)
parsedIP := net.ParseIP(ip)
if parsedIP != nil {
// Cloudflare 的 CF-Connecting-IP 总是包含真实客户端IP无论公网还是内网
// 如果同时有 CF-Ray说明确实来自 Cloudflare更加可靠
if cfRay != "" {
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", "CF-Connecting-IP",
"cfRay", cfRay,
"isPrivateIP", isPrivateIP(parsedIP))
} else {
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", "CF-Connecting-IP",
"isPrivateIP", isPrivateIP(parsedIP))
}
return ip, nil
}
}
// 2. 检查其他CDN特定的头
cdnHeaders := []string{
"True-Client-IP", // Cloudflare Enterprise, Akamai
"X-Client-IP", // 一些CDN和代理
"X-Cluster-Client-IP", // Kubernetes Ingress
}
for _, headerName := range cdnHeaders {
ipStr := c.Request.Header.Get(headerName)
if ipStr != "" {
ip := strings.TrimSpace(ipStr)
parsedIP := net.ParseIP(ip)
if parsedIP != nil {
// CDN头通常包含真实客户端IP优先返回公网IP
if !isPrivateIP(parsedIP) {
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", headerName,
"isPrivateIP", false)
return ip, nil
}
// 即使是内网IPCDN头也相对可靠
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", headerName,
"isPrivateIP", true)
return ip, nil
}
}
}
// 3. 检查标准 Forwarded 头RFC 7239- 现代标准,最可靠
forwarded := c.Request.Header.Get("Forwarded")
if forwarded != "" {
ip := parseForwardedHeader(forwarded)
if ip != "" {
parsedIP := net.ParseIP(ip)
if parsedIP != nil {
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", "Forwarded",
"isPrivateIP", isPrivateIP(parsedIP))
return ip, nil
}
}
}
// 4. 检查 X-Real-IP 头通常由第一层代理设置比X-Forwarded-For更可靠
xRealIP := c.Request.Header.Get("X-Real-IP")
if xRealIP != "" {
ip := strings.TrimSpace(xRealIP)
parsedIP := net.ParseIP(ip)
if parsedIP != nil {
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", "X-Real-IP",
"isPrivateIP", isPrivateIP(parsedIP))
return ip, nil
}
}
// 5. 检查 X-Forwarded-For 头格式client_ip, proxy1_ip, proxy2_ip, ...
// 重要在多层代理中最左边的IP是客户端真实IP应该优先取第一个IP
xForwardedFor := c.Request.Header.Get("X-Forwarded-For")
if xForwardedFor != "" {
ips := strings.Split(xForwardedFor, ",")
// 取最左边的IP第一个IP这是客户端真实IP
// 格式client_ip, proxy1_ip, proxy2_ip, ...
if len(ips) > 0 {
ip := strings.TrimSpace(ips[0])
parsedIP := net.ParseIP(ip)
if parsedIP != nil {
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", "X-Forwarded-For",
"totalIPs", len(ips),
"isPrivateIP", isPrivateIP(parsedIP),
"note", "取最左边的IP客户端真实IP")
return ip, nil
}
}
}
// 6. 检查其他常见头
otherHeaders := []string{"X-Client-IP", "X-Forwarded", "Forwarded-For"}
for _, headerName := range otherHeaders {
ipStr := c.Request.Header.Get(headerName)
if ipStr != "" {
ip := strings.TrimSpace(ipStr)
parsedIP := net.ParseIP(ip)
if parsedIP != nil {
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", headerName,
"isPrivateIP", isPrivateIP(parsedIP))
return ip, nil
}
}
}
// 7. 如果配置了自定义的 ProxyHeader检查它
if o.ProxyHeader != "" {
customHeaderIP := c.Request.Header.Get(o.ProxyHeader)
if customHeaderIP != "" {
ip := strings.TrimSpace(customHeaderIP)
parsedIP := net.ParseIP(ip)
if parsedIP != nil {
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", "Custom-"+o.ProxyHeader,
"isPrivateIP", isPrivateIP(parsedIP))
return ip, nil
}
}
}
// 8. 最后使用 RemoteAddr在集群环境中可能是代理服务器的IP
remoteAddr := c.Request.RemoteAddr
ip, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
log.ZError(c, "GetClientIP failed to parse RemoteAddr", err, "RemoteAddr", remoteAddr)
return "", errs.ErrInternalServer.WrapMsg(fmt.Sprintf("failed to parse RemoteAddr: %v", err))
}
parsedIP := net.ParseIP(ip)
log.ZInfo(c, "GetClientIP 最终选择IP",
"ip", ip,
"source", "RemoteAddr",
"isPrivateIP", parsedIP != nil && isPrivateIP(parsedIP))
return ip, nil
}
func (o *Api) GetDefaultIMAdminUserID() string {
return o.ImUserID
}

View File

@@ -0,0 +1,783 @@
package util
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"math"
"math/rand"
"time"
"github.com/openimsdk/tools/errs"
)
// GenerateCaptchaImageFromCode 根据给定的验证码生成动态GIF图片
// 返回GIF图片的字节数组
func GenerateCaptchaImageFromCode(code string) (imgBytes []byte, err error) {
// 图片尺寸 - 宽度280px高度50px
width := 280
height := 50
// GIF动画参数
frameCount := 12 // 12帧动画更流畅
delay := 8 // 每帧延迟8单位1/100秒
// 创建统一的调色板(所有帧共享)
sharedPalette := createSharedPalette()
// 创建GIF
g := &gif.GIF{
Image: []*image.Paletted{},
Delay: []int{},
}
// 使用固定种子保证数字位置基本一致
baseSeed := time.Now().UnixNano()
// 预生成每帧的基础数据(干扰线、圆圈等的位置)
noiseData := generateNoiseData(baseSeed, frameCount, width, height)
// 生成每一帧
for i := 0; i < frameCount; i++ {
frame := image.NewRGBA(image.Rect(0, 0, width, height))
// 设置背景色(浅灰色,稍微模糊)
bgColor := color.RGBA{235, 235, 235, 255}
draw.Draw(frame, frame.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
// 使用随机数生成器(每帧略有不同)
rng := rand.New(rand.NewSource(baseSeed + int64(i*1000)))
// 绘制丰富的背景 - 添加渐变背景
drawGradientBackground(frame, rng, width, height)
// 绘制移动的背景圆圈类似bubbles效果
drawAnimatedCircles(frame, noiseData, i, width, height)
// 绘制额外的背景形状(矩形、椭圆等)
drawAdditionalShapes(frame, rng, width, height)
// 添加动态干扰线(位置会变化)
drawAnimatedNoiseLines(frame, noiseData, i, width, height)
// 决定是否显示数字(每隔几帧就隐藏数字,造成闪烁效果)
// 例如0,1,2显示数字3,4隐藏5,6显示7,8隐藏9,10显示11隐藏
showNumbers := (i % 3) != 2 // 每3帧中有2帧显示数字1帧隐藏
if showNumbers {
// 绘制验证码文字(位置会抖动)
drawNumbersWithWiggle(frame, code, rng, width, height, i, frameCount)
}
// 添加动态干扰点(位置会变化)
drawAnimatedNoiseDots(frame, noiseData, i, width, height)
// 添加模糊噪点 - 增加噪点密度
addBlurNoise(frame, rng)
// 添加额外的随机干扰线
drawRandomNoiseLines(frame, rng, width, height)
// 转换为调色板图像GIF需要
// 使用统一的调色板
paletted := convertToPalettedWithPalette(frame, sharedPalette)
if paletted == nil {
return nil, errs.New(fmt.Sprintf("failed to convert frame %d to paletted", i+1))
}
g.Image = append(g.Image, paletted)
g.Delay = append(g.Delay, delay)
}
// 编码为GIF
var buf bytes.Buffer
err = gif.EncodeAll(&buf, g)
if err != nil {
return nil, errs.WrapMsg(err, "encode gif failed")
}
// 验证数据不为空
if buf.Len() == 0 {
return nil, errs.New("generated gif data is empty")
}
return buf.Bytes(), nil
}
// NoiseData 存储每帧的干扰数据
type NoiseData struct {
lines [][]LineData
circles [][]CircleData
dots [][]DotData
}
type LineData struct {
x1, y1, x2, y2 int
color color.RGBA
thickness int
}
type CircleData struct {
x, y, radius int
color color.RGBA
}
type DotData struct {
x, y int
color color.RGBA
size int
}
// generateNoiseData 预生成所有帧的干扰数据
func generateNoiseData(baseSeed int64, frameCount, width, height int) *NoiseData {
rng := rand.New(rand.NewSource(baseSeed))
data := &NoiseData{
lines: make([][]LineData, frameCount),
circles: make([][]CircleData, frameCount),
dots: make([][]DotData, frameCount),
}
// 生成干扰线数据(每帧位置略有变化)- 增加干扰线数量
lineCount := 20 // 从10增加到20
for frame := 0; frame < frameCount; frame++ {
lines := make([]LineData, lineCount)
for i := 0; i < lineCount; i++ {
// 基础位置
baseX1 := rng.Intn(width)
baseY1 := rng.Intn(height)
baseX2 := rng.Intn(width)
baseY2 := rng.Intn(height)
// 每帧添加偏移,形成移动效果
offsetX := int(float64(frame) * 2.5 * float64(rng.Float64()-0.5))
offsetY := int(float64(frame) * 2.5 * float64(rng.Float64()-0.5))
lines[i] = LineData{
x1: clampCoord(baseX1+offsetX, width),
y1: clampCoord(baseY1+offsetY, height),
x2: clampCoord(baseX2+offsetX, width),
y2: clampCoord(baseY2+offsetY, height),
color: color.RGBA{
uint8(150 + rng.Intn(80)), // 更宽的颜色范围
uint8(150 + rng.Intn(80)),
uint8(150 + rng.Intn(80)),
200 + uint8(rng.Intn(55)), // 半透明
},
thickness: 1 + rng.Intn(3), // 增加线条粗细变化
}
}
data.lines[frame] = lines
}
// 生成背景圆圈数据移动的bubbles- 增加圆圈数量和大小变化
circleCount := 15 // 从8增加到15
for frame := 0; frame < frameCount; frame++ {
circles := make([]CircleData, circleCount)
for i := 0; i < circleCount; i++ {
// 基础位置
baseX := rng.Intn(width*2) - width/2
baseY := rng.Intn(height*2) - height/2
// 每帧移动
moveX := int(float64(frame) * 1.5 * float64(rng.Float64()-0.5))
moveY := int(float64(frame) * 1.5 * float64(rng.Float64()-0.5))
circles[i] = CircleData{
x: clampCoord(baseX+moveX, width),
y: clampCoord(baseY+moveY, height),
radius: 8 + rng.Intn(35), // 更大的半径范围8-43
color: color.RGBA{
uint8(180 + rng.Intn(50)), // 更宽的颜色范围
uint8(180 + rng.Intn(50)),
uint8(180 + rng.Intn(50)),
150 + uint8(rng.Intn(105)), // 更宽的透明度范围
},
}
}
data.circles[frame] = circles
}
// 生成干扰点数据(闪烁效果)- 增加点数量
dotCount := 200 // 从100增加到200
for frame := 0; frame < frameCount; frame++ {
dots := make([]DotData, dotCount)
for i := 0; i < dotCount; i++ {
x := rng.Intn(width)
y := rng.Intn(height)
// 某些点会在某些帧消失(闪烁效果)
visible := frame%3 != i%3 || rng.Float64() > 0.3
dots[i] = DotData{
x: x,
y: y,
color: color.RGBA{
uint8(120 + rng.Intn(110)), // 更宽的颜色范围
uint8(120 + rng.Intn(110)),
uint8(120 + rng.Intn(110)),
func() uint8 {
if visible {
return 200 + uint8(rng.Intn(55))
}
return uint8(80 + rng.Intn(120))
}(),
},
size: 1 + rng.Intn(3), // 更大的点尺寸1-3
}
}
data.dots[frame] = dots
}
return data
}
func clampCoord(v, max int) int {
if v < 0 {
return 0
}
if v >= max {
return max - 1
}
return v
}
// createSharedPalette 创建统一的调色板(所有帧共享)
func createSharedPalette() color.Palette {
palette := make(color.Palette, 256)
// 添加灰色渐变0-239
for i := 0; i < 240; i++ {
val := uint8(i * 255 / 240)
palette[i] = color.RGBA{val, val, val, 255}
}
// 添加常用颜色240-255
colorMap := map[int]color.RGBA{
240: {30, 30, 30, 255},
241: {60, 60, 60, 255},
242: {50, 50, 50, 255},
243: {40, 40, 80, 255},
244: {80, 40, 40, 255},
245: {50, 80, 50, 255},
246: {100, 30, 30, 255},
247: {30, 100, 30, 255},
248: {30, 30, 100, 255},
249: {140, 140, 140, 255},
250: {160, 160, 160, 255},
251: {180, 180, 180, 255},
252: {200, 200, 200, 255},
253: {220, 220, 220, 255},
254: {235, 235, 235, 255},
255: {255, 255, 255, 255},
}
for idx, col := range colorMap {
if idx < 256 {
palette[idx] = col
}
}
return palette
}
// convertToPalettedWithPalette 使用指定调色板将RGBA图像转换为Paletted图像
func convertToPalettedWithPalette(img *image.RGBA, palette color.Palette) *image.Paletted {
bounds := img.Bounds()
// 创建Paletted图像
paletted := image.NewPaletted(bounds, palette)
// 转换:找到最接近的调色板颜色
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
c := img.At(x, y)
// 找到调色板中最接近的颜色
idx := findClosestColor(c, palette)
paletted.SetColorIndex(x, y, uint8(idx))
}
}
return paletted
}
// findClosestColor 在调色板中找到最接近的颜色索引
func findClosestColor(c color.Color, palette color.Palette) int {
r, g, b, _ := c.RGBA()
minDist := uint32(^uint32(0))
bestIdx := 0
for i := 0; i < len(palette); i++ {
cr, cg, cb, _ := palette[i].RGBA()
// 计算欧氏距离
dr := (r >> 8) - (cr >> 8)
dg := (g >> 8) - (cg >> 8)
db := (b >> 8) - (cb >> 8)
dist := dr*dr + dg*dg + db*db
if dist < minDist {
minDist = dist
bestIdx = i
}
}
return bestIdx
}
// drawGradientBackground 绘制渐变背景
func drawGradientBackground(img *image.RGBA, rng *rand.Rand, width, height int) {
// 随机选择渐变方向
vertical := rng.Intn(2) == 0
if vertical {
// 垂直渐变
startR := uint8(220 + rng.Intn(35))
startG := uint8(220 + rng.Intn(35))
startB := uint8(220 + rng.Intn(35))
endR := uint8(200 + rng.Intn(55))
endG := uint8(200 + rng.Intn(55))
endB := uint8(200 + rng.Intn(55))
for y := 0; y < height; y++ {
ratio := float64(y) / float64(height)
r := uint8(float64(startR)*(1-ratio) + float64(endR)*ratio)
g := uint8(float64(startG)*(1-ratio) + float64(endG)*ratio)
b := uint8(float64(startB)*(1-ratio) + float64(endB)*ratio)
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{r, g, b, 255})
}
}
} else {
// 水平渐变
startR := uint8(220 + rng.Intn(35))
startG := uint8(220 + rng.Intn(35))
startB := uint8(220 + rng.Intn(35))
endR := uint8(200 + rng.Intn(55))
endG := uint8(200 + rng.Intn(55))
endB := uint8(200 + rng.Intn(55))
for x := 0; x < width; x++ {
ratio := float64(x) / float64(width)
r := uint8(float64(startR)*(1-ratio) + float64(endR)*ratio)
g := uint8(float64(startG)*(1-ratio) + float64(endG)*ratio)
b := uint8(float64(startB)*(1-ratio) + float64(endB)*ratio)
for y := 0; y < height; y++ {
img.Set(x, y, color.RGBA{r, g, b, 255})
}
}
}
}
// drawAdditionalShapes 绘制额外的背景形状
func drawAdditionalShapes(img *image.RGBA, rng *rand.Rand, width, height int) {
shapeCount := 5 + rng.Intn(8) // 5-12个形状
for i := 0; i < shapeCount; i++ {
shapeType := rng.Intn(3)
x := rng.Intn(width)
y := rng.Intn(height)
// 随机颜色(浅色,半透明)
col := color.RGBA{
uint8(190 + rng.Intn(45)),
uint8(190 + rng.Intn(45)),
uint8(190 + rng.Intn(45)),
uint8(100 + rng.Intn(100)),
}
switch shapeType {
case 0: // 矩形
w := 15 + rng.Intn(40)
h := 15 + rng.Intn(40)
drawRect(img, x, y, w, h, col)
case 1: // 椭圆
rx := 10 + rng.Intn(25)
ry := 10 + rng.Intn(25)
drawEllipse(img, x, y, rx, ry, col)
case 2: // 小圆圈
radius := 5 + rng.Intn(15)
drawCircle(img, x, y, radius, col)
}
}
}
// drawRect 绘制矩形
func drawRect(img *image.RGBA, x, y, w, h int, c color.Color) {
for dy := 0; dy < h && y+dy < img.Bounds().Dy(); dy++ {
if y+dy < 0 {
continue
}
for dx := 0; dx < w && x+dx < img.Bounds().Dx(); dx++ {
if x+dx < 0 {
continue
}
r, g, b, _ := img.At(x+dx, y+dy).RGBA()
cr, cg, cb, ca := c.RGBA()
alpha := uint32(ca >> 8)
invAlpha := 255 - alpha
newR := uint8(((uint32(r>>8) * invAlpha) + (uint32(cr>>8) * alpha)) / 255)
newG := uint8(((uint32(g>>8) * invAlpha) + (uint32(cg>>8) * alpha)) / 255)
newB := uint8(((uint32(b>>8) * invAlpha) + (uint32(cb>>8) * alpha)) / 255)
img.Set(x+dx, y+dy, color.RGBA{newR, newG, newB, 255})
}
}
}
// drawEllipse 绘制椭圆
func drawEllipse(img *image.RGBA, cx, cy, rx, ry int, c color.Color) {
for y := -ry; y <= ry; y++ {
for x := -rx; x <= rx; x++ {
// 椭圆方程: (x/rx)^2 + (y/ry)^2 <= 1
if float64(x*x)/(float64(rx*rx))+float64(y*y)/(float64(ry*ry)) <= 1.0 {
px := cx + x
py := cy + y
if px >= 0 && px < img.Bounds().Dx() && py >= 0 && py < img.Bounds().Dy() {
r, g, b, _ := img.At(px, py).RGBA()
cr, cg, cb, ca := c.RGBA()
alpha := uint32(ca >> 8)
invAlpha := 255 - alpha
newR := uint8(((uint32(r>>8) * invAlpha) + (uint32(cr>>8) * alpha)) / 255)
newG := uint8(((uint32(g>>8) * invAlpha) + (uint32(cg>>8) * alpha)) / 255)
newB := uint8(((uint32(b>>8) * invAlpha) + (uint32(cb>>8) * alpha)) / 255)
img.Set(px, py, color.RGBA{newR, newG, newB, 255})
}
}
}
}
}
// drawRandomNoiseLines 绘制额外的随机干扰线
func drawRandomNoiseLines(img *image.RGBA, rng *rand.Rand, width, height int) {
lineCount := 8 + rng.Intn(12) // 8-19条随机线
for i := 0; i < lineCount; i++ {
x1 := rng.Intn(width)
y1 := rng.Intn(height)
x2 := rng.Intn(width)
y2 := rng.Intn(height)
col := color.RGBA{
uint8(160 + rng.Intn(70)),
uint8(160 + rng.Intn(70)),
uint8(160 + rng.Intn(70)),
uint8(150 + rng.Intn(105)),
}
thickness := 1 + rng.Intn(2)
drawThickLine(img, x1, y1, x2, y2, col, thickness)
}
}
// addBlurNoise 添加模糊噪点效果 - 增加噪点密度
func addBlurNoise(img *image.RGBA, rng *rand.Rand) {
// 增加随机噪点使图片看起来更模糊从1/50增加到1/30
for i := 0; i < img.Bounds().Dx()*img.Bounds().Dy()/30; i++ {
x := rng.Intn(img.Bounds().Dx())
y := rng.Intn(img.Bounds().Dy())
// 获取当前像素
r, g, b, _ := img.At(x, y).RGBA()
rVal := uint8(r >> 8)
gVal := uint8(g >> 8)
bVal := uint8(b >> 8)
// 添加轻微随机变化
noise := int8(rng.Intn(21) - 10) // -10 到 10
newR := clamp(int(rVal) + int(noise))
newG := clamp(int(gVal) + int(noise))
newB := clamp(int(bVal) + int(noise))
img.Set(x, y, color.RGBA{uint8(newR), uint8(newG), uint8(newB), 255})
}
}
func clamp(v int) int {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return v
}
// drawNumbersWithWiggle 绘制数字带抖动效果类似wiggle- 增加随机分布
func drawNumbersWithWiggle(img *image.RGBA, code string, rng *rand.Rand, width, height int, frameIndex, totalFrames int) {
// 根据宽度和高度调整字符大小适配新的280x50尺寸
charWidth := width / 18 // 约15280宽度时为15.5
charHeight := height * 4 / 5 // 约4050高度时为40
charSpacing := width / 25 // 字符间距,稍微减小以适应更小的空间
// 基础起始位置 - 更随机化
baseStartX := width/12 + rng.Intn(width/20) // 随机起始X位置
baseStartY := height/8 + rng.Intn(height/6) // 随机起始Y位置允许上下移动适配50px高度
// 数字颜色(深色但稍微模糊,降低对比度)
colors := []color.RGBA{
{30, 30, 30, 255}, // 深灰(不是纯黑,更模糊)
{60, 60, 60, 255}, // 中灰
{40, 40, 80, 255}, // 深蓝(模糊)
{80, 40, 40, 255}, // 深红(模糊)
{50, 80, 50, 255}, // 深绿(模糊)
{50, 50, 50, 255}, // 灰色
}
// 计算抖动偏移(使用正弦波创建平滑的抖动效果)
wiggleX := float64(frameIndex) / float64(totalFrames) * 2 * 3.14159
wiggleY := float64(frameIndex) / float64(totalFrames) * 2 * 3.14159 * 1.3
for i, char := range code {
// 随机选择颜色(偏深色但不要太黑,更模糊)
charColor := colors[rng.Intn(len(colors))]
// 基础位置 - 每个字符位置增加随机偏移
charRandomOffsetX := rng.Intn(width/25) - width/50 // ±5像素的随机偏移
charRandomOffsetY := rng.Intn(height/8) - height/16 // ±3像素的随机偏移
baseX := baseStartX + i*(charWidth+charSpacing) + charRandomOffsetX
baseY := baseStartY + charRandomOffsetY
// 添加抖动效果(每个字符的抖动幅度和相位不同)- 增加抖动幅度
charWiggleX := math.Sin(wiggleX+float64(i)*0.8) * 4.0 // 从2.0增加到4.0
charWiggleY := math.Sin(wiggleY+float64(i)*1.1) * 4.0 // 从2.0增加到4.0
// 额外的随机偏移 - 增加随机范围
xOffset := int(charWiggleX) + rng.Intn(7) - 3 // 从±1增加到±3
yOffset := int(charWiggleY) + rng.Intn(7) - 3 // 从±1增加到±3
x := baseX + xOffset
y := baseY + yOffset
// 确保不超出边界
if x < 0 {
x = 0
}
if x+charWidth > width {
x = width - charWidth
}
if y < 0 {
y = 0
}
if y+charHeight > height {
y = height - charHeight
}
// 绘制数字(使用简化版本:绘制矩形块模拟数字)
drawSimpleNumber(img, byte(char), x, y, charWidth, charHeight, charColor)
}
}
// drawSimpleNumber 绘制单个数字(简化版本,根据尺寸缩放)
func drawSimpleNumber(img *image.RGBA, digit byte, x, y, width, height int, c color.Color) {
digitValue := int(digit - '0')
// 缩放比例相对于原始16x24大小
scaleX := float64(width) / 16.0
scaleY := float64(height) / 24.0
// 根据数字绘制不同的小矩形块来模拟数字形状(原始坐标)
basePatterns := [][]struct{ x, y, w, h int }{
// 0
{{2, 2, 14, 2}, {2, 2, 2, 20}, {14, 2, 2, 20}, {2, 20, 14, 2}},
// 1
{{8, 2, 2, 20}},
// 2
{{2, 2, 12, 2}, {12, 2, 2, 10}, {2, 10, 12, 2}, {2, 10, 2, 10}, {2, 20, 12, 2}},
// 3
{{2, 2, 12, 2}, {12, 2, 2, 20}, {2, 10, 10, 2}, {2, 20, 12, 2}},
// 4
{{2, 2, 2, 10}, {2, 10, 10, 2}, {12, 2, 2, 20}},
// 5
{{2, 2, 12, 2}, {2, 2, 2, 10}, {2, 10, 12, 2}, {12, 10, 2, 10}, {2, 20, 12, 2}},
// 6
{{2, 2, 12, 2}, {2, 2, 2, 20}, {12, 10, 2, 10}, {2, 10, 10, 2}, {2, 20, 12, 2}},
// 7
{{2, 2, 12, 2}, {12, 2, 2, 20}},
// 8
{{2, 2, 12, 2}, {2, 2, 2, 10}, {12, 2, 2, 10}, {2, 10, 12, 2}, {2, 10, 2, 10}, {12, 10, 2, 10}, {2, 20, 12, 2}},
// 9
{{2, 2, 12, 2}, {2, 2, 2, 10}, {12, 2, 2, 20}, {2, 10, 10, 2}},
}
if digitValue >= 0 && digitValue <= 9 {
pattern := basePatterns[digitValue]
for _, p := range pattern {
// 缩放坐标
px := int(float64(p.x) * scaleX)
py := int(float64(p.y) * scaleY)
pw := int(float64(p.w) * scaleX)
ph := int(float64(p.h) * scaleY)
// 确保最小尺寸
if pw < 1 {
pw = 1
}
if ph < 1 {
ph = 1
}
rect := image.Rect(x+px, y+py, x+px+pw, y+py+ph)
draw.Draw(img, rect, &image.Uniform{c}, image.Point{}, draw.Over)
}
}
}
// drawAnimatedCircles 绘制移动的背景圆圈
func drawAnimatedCircles(img *image.RGBA, data *NoiseData, frameIndex int, width, height int) {
if frameIndex >= len(data.circles) {
return
}
for _, circle := range data.circles[frameIndex] {
drawCircle(img, circle.x, circle.y, circle.radius, circle.color)
}
}
// drawCircle 绘制圆圈
func drawCircle(img *image.RGBA, cx, cy, radius int, c color.Color) {
for y := -radius; y <= radius; y++ {
for x := -radius; x <= radius; x++ {
if x*x+y*y <= radius*radius {
px := cx + x
py := cy + y
if px >= 0 && px < img.Bounds().Dx() && py >= 0 && py < img.Bounds().Dy() {
// 获取当前像素并混合颜色
r, g, b, _ := img.At(px, py).RGBA()
cr, cg, cb, ca := c.RGBA()
// Alpha混合
alpha := uint32(ca >> 8)
invAlpha := 255 - alpha
newR := uint8(((uint32(r>>8) * invAlpha) + (uint32(cr>>8) * alpha)) / 255)
newG := uint8(((uint32(g>>8) * invAlpha) + (uint32(cg>>8) * alpha)) / 255)
newB := uint8(((uint32(b>>8) * invAlpha) + (uint32(cb>>8) * alpha)) / 255)
img.Set(px, py, color.RGBA{newR, newG, newB, 255})
}
}
}
}
}
// drawAnimatedNoiseLines 绘制动态干扰线
func drawAnimatedNoiseLines(img *image.RGBA, data *NoiseData, frameIndex int, width, height int) {
if frameIndex >= len(data.lines) {
return
}
for _, line := range data.lines[frameIndex] {
drawThickLine(img, line.x1, line.y1, line.x2, line.y2, line.color, line.thickness)
}
}
// drawAnimatedNoiseDots 绘制动态干扰点
func drawAnimatedNoiseDots(img *image.RGBA, data *NoiseData, frameIndex int, width, height int) {
if frameIndex >= len(data.dots) {
return
}
for _, dot := range data.dots[frameIndex] {
if dot.color.A < 50 {
continue // 跳过几乎透明的点
}
// 绘制点可能是1x1或2x2
for dx := 0; dx < dot.size && dot.x+dx < width; dx++ {
for dy := 0; dy < dot.size && dot.y+dy < height; dy++ {
img.Set(dot.x+dx, dot.y+dy, dot.color)
}
}
}
}
// drawThickLine 绘制粗线
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color, thickness int) {
for i := 0; i < thickness; i++ {
offsetX := i - thickness/2
offsetY := i - thickness/2
drawLine(img, x1+offsetX, y1+offsetY, x2+offsetX, y2+offsetY, c)
}
}
// drawLine 绘制直线Bresenham算法简化版
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) {
dx := abs(x2 - x1)
dy := abs(y2 - y1)
sx := 1
if x1 > x2 {
sx = -1
}
sy := 1
if y1 > y2 {
sy = -1
}
err := dx - dy
x, y := x1, y1
for {
if x >= 0 && x < img.Bounds().Dx() && y >= 0 && y < img.Bounds().Dy() {
img.Set(x, y, c)
}
if x == x2 && y == y2 {
break
}
e2 := 2 * err
if e2 > -dy {
err -= dy
x += sx
}
if e2 < dx {
err += dx
y += sy
}
}
}
// addNoiseDots 添加干扰点(更多更密集)
func addNoiseDots(img *image.RGBA, rng *rand.Rand, count int) {
for i := 0; i < count; i++ {
x := rng.Intn(img.Bounds().Dx())
y := rng.Intn(img.Bounds().Dy())
// 随机颜色(浅色,增加模糊效果)
brightness := 140 + rng.Intn(90) // 140-230之间
dotColor := color.RGBA{
uint8(brightness + rng.Intn(30) - 15),
uint8(brightness + rng.Intn(30) - 15),
uint8(brightness + rng.Intn(30) - 15),
255,
}
// 有时绘制小区域而不是单个点(更密集的干扰)
if rng.Intn(3) == 0 {
// 绘制2x2的小块
for dx := 0; dx < 2 && x+dx < img.Bounds().Dx(); dx++ {
for dy := 0; dy < 2 && y+dy < img.Bounds().Dy(); dy++ {
img.Set(x+dx, y+dy, dotColor)
}
}
} else {
img.Set(x, y, dotColor)
}
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

1143
internal/rpc/admin/admin.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
// 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 admin
import (
"context"
"strings"
"time"
"github.com/openimsdk/tools/utils/datautil"
"github.com/google/uuid"
"github.com/openimsdk/tools/errs"
"git.imall.cloud/openim/chat/pkg/common/constant"
admindb "git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
"git.imall.cloud/openim/chat/pkg/protocol/common"
)
func (o *adminServer) AddApplet(ctx context.Context, req *admin.AddAppletReq) (*admin.AddAppletResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if req.Name == "" {
return nil, errs.ErrArgs.WrapMsg("name empty")
}
if req.AppID == "" {
return nil, errs.ErrArgs.WrapMsg("appid empty")
}
if !(req.Status == constant.StatusOnShelf || req.Status == constant.StatusUnShelf) {
return nil, errs.ErrArgs.WrapMsg("invalid status")
}
m := admindb.Applet{
ID: req.Id,
Name: req.Name,
AppID: req.AppID,
Icon: req.Icon,
URL: req.Url,
MD5: req.Md5,
Size: req.Size,
Version: req.Version,
Priority: req.Priority,
Status: uint8(req.Status),
CreateTime: time.Now(),
}
if m.ID == "" {
m.ID = uuid.New().String()
}
if err := o.Database.CreateApplet(ctx, []*admindb.Applet{&m}); err != nil {
return nil, err
}
return &admin.AddAppletResp{}, nil
}
func (o *adminServer) DelApplet(ctx context.Context, req *admin.DelAppletReq) (*admin.DelAppletResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.AppletIds) == 0 {
return nil, errs.ErrArgs.WrapMsg("AppletIds empty")
}
applets, err := o.Database.FindApplet(ctx, req.AppletIds)
if err != nil {
return nil, err
}
if ids := datautil.Single(req.AppletIds, datautil.Slice(applets, func(e *admindb.Applet) string { return e.ID })); len(ids) > 0 {
return nil, errs.ErrArgs.WrapMsg("ids not found: " + strings.Join(ids, ", "))
}
if err := o.Database.DelApplet(ctx, req.AppletIds); err != nil {
return nil, err
}
return &admin.DelAppletResp{}, nil
}
func (o *adminServer) UpdateApplet(ctx context.Context, req *admin.UpdateAppletReq) (*admin.UpdateAppletResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
_, err := o.Database.GetApplet(ctx, req.Id)
if err != nil {
return nil, err
}
update, err := ToDBAppletUpdate(req)
if err != nil {
return nil, err
}
if err := o.Database.UpdateApplet(ctx, req.Id, update); err != nil {
return nil, err
}
return &admin.UpdateAppletResp{}, nil
}
func (o *adminServer) FindApplet(ctx context.Context, req *admin.FindAppletReq) (*admin.FindAppletResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
applets, err := o.Database.FindOnShelf(ctx)
if err != nil {
return nil, err
}
resp := &admin.FindAppletResp{Applets: make([]*common.AppletInfo, 0, len(applets))}
for _, applet := range applets {
resp.Applets = append(resp.Applets, &common.AppletInfo{
Id: applet.ID,
Name: applet.Name,
AppID: applet.AppID,
Icon: applet.Icon,
Url: applet.URL,
Md5: applet.MD5,
Size: applet.Size,
Version: applet.Version,
Priority: applet.Priority,
Status: uint32(applet.Status),
CreateTime: applet.CreateTime.UnixMilli(),
})
}
return resp, nil
}
func (o *adminServer) SearchApplet(ctx context.Context, req *admin.SearchAppletReq) (*admin.SearchAppletResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
total, applets, err := o.Database.SearchApplet(ctx, req.Keyword, req.Pagination)
if err != nil {
return nil, err
}
resp := &admin.SearchAppletResp{Total: uint32(total), Applets: make([]*common.AppletInfo, 0, len(applets))}
for _, applet := range applets {
resp.Applets = append(resp.Applets, &common.AppletInfo{
Id: applet.ID,
Name: applet.Name,
AppID: applet.AppID,
Icon: applet.Icon,
Url: applet.URL,
Md5: applet.MD5,
Size: applet.Size,
Version: applet.Version,
Priority: applet.Priority,
Status: uint32(applet.Status),
CreateTime: applet.CreateTime.UnixMilli(),
})
}
return resp, nil
}

View File

@@ -0,0 +1,289 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
admindb "git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/utils/datautil"
"github.com/redis/go-redis/v9"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
func IsNotFound(err error) bool {
switch errs.Unwrap(err) {
case redis.Nil, mongo.ErrNoDocuments:
return true
default:
return false
}
}
func (o *adminServer) db2pbApplication(val *admindb.Application) *admin.ApplicationVersion {
return &admin.ApplicationVersion{
Id: val.ID.Hex(),
Platform: val.Platform,
Version: val.Version,
Url: val.Url,
Text: val.Text,
Force: val.Force,
Latest: val.Latest,
Hot: val.Hot,
CreateTime: val.CreateTime.UnixMilli(),
}
}
// LatestVersionAPIResponse 外部 API 响应结构
type LatestVersionAPIResponse struct {
Success bool `json:"success"`
ErrorCode string `json:"errorCode"`
ErrorMessage string `json:"errorMessage"`
ShowType int `json:"showType"`
Data struct {
APKPath string `json:"apk_path"`
APKSize int64 `json:"apk_size"`
AppLogo string `json:"app_logo"`
AppName string `json:"app_name"`
CreatedAt string `json:"created_at"`
Success bool `json:"success"`
Version string `json:"version"`
} `json:"data"`
}
func (o *adminServer) LatestApplicationVersion(ctx context.Context, req *admin.LatestApplicationVersionReq) (*admin.LatestApplicationVersionResp, error) {
// 从系统配置读取 build_app_id
buildAppIDConfig, err := o.ChatDatabase.GetSystemConfig(ctx, "build_app_id")
if err != nil {
log.ZWarn(ctx, "Failed to get build_app_id from system config, falling back to database", err)
// 如果获取配置失败,回退到从数据库读取
res, err := o.Database.LatestVersion(ctx, req.Platform)
if err == nil {
return &admin.LatestApplicationVersionResp{Version: o.db2pbApplication(res)}, nil
} else if IsNotFound(err) {
return &admin.LatestApplicationVersionResp{}, nil
} else {
return nil, err
}
}
buildAppID := buildAppIDConfig.Value
if buildAppID == "" {
log.ZWarn(ctx, "build_app_id is empty in system config, falling back to database", nil)
// 如果配置值为空,回退到从数据库读取
res, err := o.Database.LatestVersion(ctx, req.Platform)
if err == nil {
return &admin.LatestApplicationVersionResp{Version: o.db2pbApplication(res)}, nil
} else if IsNotFound(err) {
return &admin.LatestApplicationVersionResp{}, nil
} else {
return nil, err
}
}
// 调用外部 API
apiURL := "https://down.imall.cloud/api/download/latest"
requestBody := map[string]string{
"app_id": buildAppID,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
log.ZError(ctx, "Failed to marshal request body", err, "buildAppID", buildAppID)
return nil, errs.ErrInternalServer.WrapMsg("failed to prepare request")
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
if err != nil {
log.ZError(ctx, "Failed to create HTTP request", err, "url", apiURL)
return nil, errs.ErrInternalServer.WrapMsg("failed to create request")
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(httpReq)
if err != nil {
log.ZError(ctx, "Failed to call external API", err, "url", apiURL)
// API 调用失败,回退到从数据库读取
res, err := o.Database.LatestVersion(ctx, req.Platform)
if err == nil {
return &admin.LatestApplicationVersionResp{Version: o.db2pbApplication(res)}, nil
} else if IsNotFound(err) {
return &admin.LatestApplicationVersionResp{}, nil
} else {
return nil, err
}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.ZError(ctx, "Failed to read response body", err)
// 读取响应失败,回退到从数据库读取
res, err := o.Database.LatestVersion(ctx, req.Platform)
if err == nil {
return &admin.LatestApplicationVersionResp{Version: o.db2pbApplication(res)}, nil
} else if IsNotFound(err) {
return &admin.LatestApplicationVersionResp{}, nil
} else {
return nil, err
}
}
if resp.StatusCode != http.StatusOK {
log.ZWarn(ctx, "External API returned non-200 status", nil, "statusCode", resp.StatusCode, "body", string(body))
// API 返回非 200回退到从数据库读取
res, err := o.Database.LatestVersion(ctx, req.Platform)
if err == nil {
return &admin.LatestApplicationVersionResp{Version: o.db2pbApplication(res)}, nil
} else if IsNotFound(err) {
return &admin.LatestApplicationVersionResp{}, nil
} else {
return nil, err
}
}
var apiResp LatestVersionAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
log.ZError(ctx, "Failed to unmarshal API response", err, "body", string(body))
// 解析响应失败,回退到从数据库读取
res, err := o.Database.LatestVersion(ctx, req.Platform)
if err == nil {
return &admin.LatestApplicationVersionResp{Version: o.db2pbApplication(res)}, nil
} else if IsNotFound(err) {
return &admin.LatestApplicationVersionResp{}, nil
} else {
return nil, err
}
}
if !apiResp.Success {
log.ZWarn(ctx, "External API returned success=false", nil, "errorCode", apiResp.ErrorCode, "errorMessage", apiResp.ErrorMessage)
// API 返回失败,回退到从数据库读取
res, err := o.Database.LatestVersion(ctx, req.Platform)
if err == nil {
return &admin.LatestApplicationVersionResp{Version: o.db2pbApplication(res)}, nil
} else if IsNotFound(err) {
return &admin.LatestApplicationVersionResp{}, nil
} else {
return nil, err
}
}
// 解析创建时间
var createTime time.Time
if apiResp.Data.CreatedAt != "" {
createTime, err = time.Parse(time.RFC3339, apiResp.Data.CreatedAt)
if err != nil {
log.ZWarn(ctx, "Failed to parse created_at", err, "createdAt", apiResp.Data.CreatedAt)
createTime = time.Now()
}
} else {
createTime = time.Now()
}
// 转换为 ApplicationVersion 格式
version := &admin.ApplicationVersion{
Id: "", // 外部 API 没有 ID
Platform: req.Platform,
Version: apiResp.Data.Version,
Url: apiResp.Data.APKPath,
Text: fmt.Sprintf("应用名称: %s", apiResp.Data.AppName),
Force: false, // 外部 API 没有提供此字段
Latest: true, // 从 latest 接口获取的肯定是最新版本
Hot: false, // 外部 API 没有提供此字段
CreateTime: createTime.UnixMilli(),
}
return &admin.LatestApplicationVersionResp{Version: version}, nil
}
func (o *adminServer) AddApplicationVersion(ctx context.Context, req *admin.AddApplicationVersionReq) (*admin.AddApplicationVersionResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
val := &admindb.Application{
ID: primitive.NewObjectID(),
Platform: req.Platform,
Version: req.Version,
Url: req.Url,
Text: req.Text,
Force: req.Force,
Latest: req.Latest,
Hot: req.Hot,
CreateTime: time.Now(),
}
if err := o.Database.AddVersion(ctx, val); err != nil {
return nil, err
}
return &admin.AddApplicationVersionResp{}, nil
}
func (o *adminServer) UpdateApplicationVersion(ctx context.Context, req *admin.UpdateApplicationVersionReq) (*admin.UpdateApplicationVersionResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
oid, err := primitive.ObjectIDFromHex(req.Id)
if err != nil {
return nil, errs.ErrArgs.WrapMsg("invalid id " + err.Error())
}
update := make(map[string]any)
putUpdate(update, "platform", req.Platform)
putUpdate(update, "version", req.Version)
putUpdate(update, "url", req.Url)
putUpdate(update, "text", req.Text)
putUpdate(update, "force", req.Force)
putUpdate(update, "latest", req.Latest)
putUpdate(update, "hot", req.Hot)
if err := o.Database.UpdateVersion(ctx, oid, update); err != nil {
return nil, err
}
return &admin.UpdateApplicationVersionResp{}, nil
}
func (o *adminServer) DeleteApplicationVersion(ctx context.Context, req *admin.DeleteApplicationVersionReq) (*admin.DeleteApplicationVersionResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
ids := make([]primitive.ObjectID, 0, len(req.Id))
for _, id := range req.Id {
oid, err := primitive.ObjectIDFromHex(id)
if err != nil {
return nil, errs.ErrArgs.WrapMsg("invalid id " + err.Error())
}
ids = append(ids, oid)
}
if err := o.Database.DeleteVersion(ctx, ids); err != nil {
return nil, err
}
return &admin.DeleteApplicationVersionResp{}, nil
}
func (o *adminServer) PageApplicationVersion(ctx context.Context, req *admin.PageApplicationVersionReq) (*admin.PageApplicationVersionResp, error) {
total, res, err := o.Database.PageVersion(ctx, req.Platform, req.Pagination)
if err != nil {
return nil, err
}
return &admin.PageApplicationVersionResp{
Total: total,
Versions: datautil.Slice(res, o.db2pbApplication),
}, nil
}
func putUpdate[T any](update map[string]any, name string, val interface{ GetValuePtr() *T }) {
ptrVal := val.GetValuePtr()
if ptrVal == nil {
return
}
update[name] = *ptrVal
}

View File

@@ -0,0 +1,70 @@
// 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 admin
import (
"context"
"git.imall.cloud/openim/chat/pkg/common/db/dbutil"
"git.imall.cloud/openim/chat/pkg/eerrs"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
)
func (o *adminServer) CheckRegisterForbidden(ctx context.Context, req *admin.CheckRegisterForbiddenReq) (*admin.CheckRegisterForbiddenResp, error) {
forbiddens, err := o.Database.FindIPForbidden(ctx, []string{req.Ip})
if err != nil {
return nil, err
}
for _, forbidden := range forbiddens {
if forbidden.LimitRegister {
return nil, eerrs.ErrForbidden.Wrap()
}
}
return &admin.CheckRegisterForbiddenResp{}, nil
}
func (o *adminServer) CheckLoginForbidden(ctx context.Context, req *admin.CheckLoginForbiddenReq) (*admin.CheckLoginForbiddenResp, error) {
forbiddens, err := o.Database.FindIPForbidden(ctx, []string{req.Ip})
if err != nil {
return nil, err
}
for _, forbidden := range forbiddens {
if forbidden.LimitLogin {
return nil, eerrs.ErrForbidden.WrapMsg("ip forbidden")
}
}
if _, err := o.Database.GetLimitUserLoginIP(ctx, req.UserID, req.Ip); err != nil {
if !dbutil.IsDBNotFound(err) {
return nil, err
}
count, err := o.Database.CountLimitUserLoginIP(ctx, req.UserID)
if err != nil {
return nil, err
}
if count > 0 {
return nil, eerrs.ErrForbidden.WrapMsg("user ip forbidden")
}
}
if forbiddenAccount, err := o.Database.GetBlockInfo(ctx, req.UserID); err == nil {
reason := "账户已被封禁"
if forbiddenAccount.Reason != "" {
reason = "账户已被封禁:" + forbiddenAccount.Reason
}
return nil, eerrs.ErrAccountBlocked.WrapMsg(reason)
} else if !dbutil.IsDBNotFound(err) {
return nil, err
}
return &admin.CheckLoginForbiddenResp{}, nil
}

View File

@@ -0,0 +1,55 @@
// 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 admin
import (
"context"
"github.com/openimsdk/tools/errs"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
)
func (o *adminServer) GetClientConfig(ctx context.Context, req *admin.GetClientConfigReq) (*admin.GetClientConfigResp, error) {
conf, err := o.Database.GetConfig(ctx)
if err != nil {
return nil, err
}
return &admin.GetClientConfigResp{Config: conf}, nil
}
func (o *adminServer) SetClientConfig(ctx context.Context, req *admin.SetClientConfigReq) (*admin.SetClientConfigResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.Config) == 0 {
return nil, errs.ErrArgs.WrapMsg("update config empty")
}
if err := o.Database.SetConfig(ctx, req.Config); err != nil {
return nil, err
}
return &admin.SetClientConfigResp{}, nil
}
func (o *adminServer) DelClientConfig(ctx context.Context, req *admin.DelClientConfigReq) (*admin.DelClientConfigResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if err := o.Database.DelConfig(ctx, req.Keys); err != nil {
return nil, err
}
return &admin.DelClientConfigResp{}, nil
}

View File

@@ -0,0 +1,217 @@
// 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 admin
import (
"context"
"math/rand"
"strings"
"time"
"github.com/openimsdk/tools/utils/datautil"
"github.com/openimsdk/tools/errs"
admindb "git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/eerrs"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
)
func (o *adminServer) AddInvitationCode(ctx context.Context, req *admin.AddInvitationCodeReq) (*admin.AddInvitationCodeResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.Codes) == 0 {
return nil, errs.ErrArgs.WrapMsg("codes is empty")
}
if datautil.Duplicate(req.Codes) {
return nil, errs.ErrArgs.WrapMsg("codes is duplicate")
}
irs, err := o.Database.FindInvitationRegister(ctx, req.Codes)
if err != nil {
return nil, err
}
if len(irs) > 0 {
ids := datautil.Slice(irs, func(info *admindb.InvitationRegister) string { return info.InvitationCode })
return nil, errs.ErrArgs.WrapMsg("code existed", "ids", ids)
}
now := time.Now()
codes := make([]*admindb.InvitationRegister, 0, len(req.Codes))
for _, code := range req.Codes {
codes = append(codes, &admindb.InvitationRegister{
InvitationCode: code,
UsedByUserID: "",
CreateTime: now,
})
}
if err := o.Database.CreatInvitationRegister(ctx, codes); err != nil {
return nil, err
}
return &admin.AddInvitationCodeResp{}, nil
}
func (o *adminServer) GenInvitationCode(ctx context.Context, req *admin.GenInvitationCodeReq) (*admin.GenInvitationCodeResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if req.Num <= 0 || req.Len <= 0 {
return nil, errs.ErrArgs.WrapMsg("num or len <= 0")
}
if len(req.Chars) == 0 {
req.Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
}
now := time.Now()
invitationRegisters := make([]*admindb.InvitationRegister, 0, req.Num)
codes := make([]string, 0, req.Num)
for i := int32(0); i < req.Num; i++ {
buf := make([]byte, req.Len)
rand.Read(buf)
for i, b := range buf {
buf[i] = req.Chars[b%byte(len(req.Chars))]
}
codes = append(codes, string(buf))
invitationRegisters = append(invitationRegisters, &admindb.InvitationRegister{
InvitationCode: string(buf),
UsedByUserID: "",
CreateTime: now,
})
}
if datautil.Duplicate(codes) {
return nil, errs.ErrArgs.WrapMsg("gen duplicate codes")
}
irs, err := o.Database.FindInvitationRegister(ctx, codes)
if err != nil {
return nil, err
}
if len(irs) > 0 {
ids := datautil.Single(codes, datautil.Slice(irs, func(ir *admindb.InvitationRegister) string { return ir.InvitationCode }))
return nil, errs.ErrArgs.WrapMsg(strings.Join(ids, ", "))
}
if err := o.Database.CreatInvitationRegister(ctx, invitationRegisters); err != nil {
return nil, err
}
return &admin.GenInvitationCodeResp{}, nil
}
func (o *adminServer) FindInvitationCode(ctx context.Context, req *admin.FindInvitationCodeReq) (*admin.FindInvitationCodeResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
if len(req.Codes) == 0 {
return nil, errs.ErrArgs.WrapMsg("codes is empty")
}
invitationRegisters, err := o.Database.FindInvitationRegister(ctx, req.Codes)
if err != nil {
return nil, err
}
userIDs := make([]string, 0, len(invitationRegisters))
for _, register := range invitationRegisters {
if register.UsedByUserID != "" {
userIDs = append(userIDs, register.UsedByUserID)
}
}
userMap, err := o.Chat.MapUserPublicInfo(ctx, userIDs)
if err != nil {
return nil, err
}
resp := &admin.FindInvitationCodeResp{Codes: make([]*admin.InvitationRegister, 0, len(invitationRegisters))}
for _, register := range invitationRegisters {
resp.Codes = append(resp.Codes, &admin.InvitationRegister{
InvitationCode: register.InvitationCode,
CreateTime: register.CreateTime.UnixMilli(),
UsedUserID: register.UsedByUserID,
UsedUser: userMap[register.UsedByUserID],
})
}
return resp, nil
}
func (o *adminServer) UseInvitationCode(ctx context.Context, req *admin.UseInvitationCodeReq) (*admin.UseInvitationCodeResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
codes, err := o.Database.FindInvitationRegister(ctx, []string{req.Code})
if err != nil {
return nil, err
}
if len(codes) == 0 {
return nil, eerrs.ErrInvitationNotFound.Wrap()
}
if codes[0].UsedByUserID != "" {
return nil, eerrs.ErrInvitationCodeUsed.Wrap()
}
if err := o.Database.UpdateInvitationRegister(ctx, req.Code, ToDBInvitationRegisterUpdate(req.UserID)); err != nil {
return nil, err
}
return &admin.UseInvitationCodeResp{}, nil
}
func (o *adminServer) DelInvitationCode(ctx context.Context, req *admin.DelInvitationCodeReq) (*admin.DelInvitationCodeResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.Codes) == 0 {
return nil, errs.ErrArgs.WrapMsg("codes is empty")
}
if datautil.Duplicate(req.Codes) {
return nil, errs.ErrArgs.WrapMsg("codes is duplicate")
}
irs, err := o.Database.FindInvitationRegister(ctx, req.Codes)
if err != nil {
return nil, err
}
if len(irs) != len(req.Codes) {
ids := datautil.Single(req.Codes, datautil.Slice(irs, func(ir *admindb.InvitationRegister) string { return ir.InvitationCode }))
return nil, errs.ErrArgs.WrapMsg("code not found " + strings.Join(ids, ", "))
}
if err := o.Database.DelInvitationRegister(ctx, req.Codes); err != nil {
return nil, err
}
return &admin.DelInvitationCodeResp{}, nil
}
func (o *adminServer) SearchInvitationCode(ctx context.Context, req *admin.SearchInvitationCodeReq) (*admin.SearchInvitationCodeResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
total, list, err := o.Database.SearchInvitationRegister(ctx, req.Keyword, req.Status, req.UserIDs, req.Codes, req.Pagination)
if err != nil {
return nil, err
}
userIDs := make([]string, 0, len(list))
for _, register := range list {
if register.UsedByUserID != "" {
userIDs = append(userIDs, register.UsedByUserID)
}
}
userMap, err := o.Chat.MapUserPublicInfo(ctx, userIDs)
if err != nil {
return nil, err
}
invitationRegisters := make([]*admin.InvitationRegister, 0, len(list))
for _, register := range list {
invitationRegisters = append(invitationRegisters, &admin.InvitationRegister{
InvitationCode: register.InvitationCode,
CreateTime: register.CreateTime.UnixMilli(),
UsedUserID: register.UsedByUserID,
UsedUser: userMap[register.UsedByUserID],
})
}
return &admin.SearchInvitationCodeResp{
Total: uint32(total),
List: invitationRegisters,
}, nil
}

View File

@@ -0,0 +1,77 @@
// 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 admin
import (
"context"
"time"
admindb "git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
)
func (o *adminServer) SearchIPForbidden(ctx context.Context, req *admin.SearchIPForbiddenReq) (*admin.SearchIPForbiddenResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
total, forbiddens, err := o.Database.SearchIPForbidden(ctx, req.Keyword, req.Status, req.Pagination)
if err != nil {
return nil, err
}
resp := &admin.SearchIPForbiddenResp{
Forbiddens: make([]*admin.IPForbidden, 0, len(forbiddens)),
Total: uint32(total),
}
for _, forbidden := range forbiddens {
resp.Forbiddens = append(resp.Forbiddens, &admin.IPForbidden{
Ip: forbidden.IP,
LimitLogin: forbidden.LimitLogin,
LimitRegister: forbidden.LimitRegister,
CreateTime: forbidden.CreateTime.UnixMilli(),
})
}
return resp, nil
}
func (o *adminServer) AddIPForbidden(ctx context.Context, req *admin.AddIPForbiddenReq) (*admin.AddIPForbiddenResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
now := time.Now()
tables := make([]*admindb.IPForbidden, 0, len(req.Forbiddens))
for _, forbidden := range req.Forbiddens {
tables = append(tables, &admindb.IPForbidden{
IP: forbidden.Ip,
LimitLogin: forbidden.LimitLogin,
LimitRegister: forbidden.LimitRegister,
CreateTime: now,
})
}
if err := o.Database.AddIPForbidden(ctx, tables); err != nil {
return nil, err
}
return &admin.AddIPForbiddenResp{}, nil
}
func (o *adminServer) DelIPForbidden(ctx context.Context, req *admin.DelIPForbiddenReq) (*admin.DelIPForbiddenResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if err := o.Database.DelIPForbidden(ctx, req.Ips); err != nil {
return nil, err
}
return &admin.DelIPForbiddenResp{}, nil
}

View File

@@ -0,0 +1,134 @@
// 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 admin
import (
"context"
"time"
"github.com/openimsdk/tools/utils/datautil"
"github.com/openimsdk/tools/errs"
admindb "git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
"git.imall.cloud/openim/chat/pkg/protocol/common"
)
func (o *adminServer) AddDefaultFriend(ctx context.Context, req *admin.AddDefaultFriendReq) (*admin.AddDefaultFriendResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.UserIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("user ids is empty")
}
if datautil.Duplicate(req.UserIDs) {
return nil, errs.ErrArgs.WrapMsg("user ids is duplicate")
}
users, err := o.Chat.FindUserPublicInfo(ctx, req.UserIDs)
if err != nil {
return nil, err
}
if ids := datautil.Single(req.UserIDs, datautil.Slice(users, func(user *common.UserPublicInfo) string { return user.UserID })); len(ids) > 0 {
return nil, errs.ErrRecordNotFound.WrapMsg("user id not found", "userID", ids)
}
exists, err := o.Database.FindDefaultFriend(ctx, req.UserIDs)
if err != nil {
return nil, err
}
if len(exists) > 0 {
return nil, errs.ErrDuplicateKey.WrapMsg("user id existed", "userID", exists)
}
now := time.Now()
ms := make([]*admindb.RegisterAddFriend, 0, len(req.UserIDs))
for _, userID := range req.UserIDs {
ms = append(ms, &admindb.RegisterAddFriend{
UserID: userID,
CreateTime: now,
})
}
if err := o.Database.AddDefaultFriend(ctx, ms); err != nil {
return nil, err
}
return &admin.AddDefaultFriendResp{}, nil
}
func (o *adminServer) DelDefaultFriend(ctx context.Context, req *admin.DelDefaultFriendReq) (*admin.DelDefaultFriendResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.UserIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("user ids is empty")
}
if datautil.Duplicate(req.UserIDs) {
return nil, errs.ErrArgs.WrapMsg("user ids is duplicate")
}
exists, err := o.Database.FindDefaultFriend(ctx, req.UserIDs)
if err != nil {
return nil, err
}
if ids := datautil.Single(req.UserIDs, exists); len(ids) > 0 {
return nil, errs.ErrRecordNotFound.WrapMsg("user id not found", "userID", ids)
}
now := time.Now()
ms := make([]*admindb.RegisterAddFriend, 0, len(req.UserIDs))
for _, userID := range req.UserIDs {
ms = append(ms, &admindb.RegisterAddFriend{
UserID: userID,
CreateTime: now,
})
}
if err := o.Database.DelDefaultFriend(ctx, req.UserIDs); err != nil {
return nil, err
}
return &admin.DelDefaultFriendResp{}, nil
}
func (o *adminServer) FindDefaultFriend(ctx context.Context, req *admin.FindDefaultFriendReq) (*admin.FindDefaultFriendResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
userIDs, err := o.Database.FindDefaultFriend(ctx, nil)
if err != nil {
return nil, err
}
return &admin.FindDefaultFriendResp{UserIDs: userIDs}, nil
}
func (o *adminServer) SearchDefaultFriend(ctx context.Context, req *admin.SearchDefaultFriendReq) (*admin.SearchDefaultFriendResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
total, infos, err := o.Database.SearchDefaultFriend(ctx, req.Keyword, req.Pagination)
if err != nil {
return nil, err
}
userIDs := datautil.Slice(infos, func(info *admindb.RegisterAddFriend) string { return info.UserID })
userMap, err := o.Chat.MapUserPublicInfo(ctx, userIDs)
if err != nil {
return nil, err
}
attributes := make([]*admin.DefaultFriendAttribute, 0, len(infos))
for _, info := range infos {
attribute := &admin.DefaultFriendAttribute{
UserID: info.UserID,
CreateTime: info.CreateTime.UnixMilli(),
User: userMap[info.UserID],
}
attributes = append(attributes, attribute)
}
return &admin.SearchDefaultFriendResp{Total: uint32(total), Users: attributes}, nil
}

View File

@@ -0,0 +1,112 @@
// 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 admin
import (
"context"
"time"
"github.com/openimsdk/tools/utils/datautil"
"github.com/openimsdk/tools/errs"
admindb "git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
)
func (o *adminServer) AddDefaultGroup(ctx context.Context, req *admin.AddDefaultGroupReq) (*admin.AddDefaultGroupResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.GroupIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("group ids is empty")
}
if datautil.Duplicate(req.GroupIDs) {
return nil, errs.ErrArgs.WrapMsg("group ids is duplicate")
}
exists, err := o.Database.FindDefaultGroup(ctx, req.GroupIDs)
if err != nil {
return nil, err
}
if len(exists) > 0 {
return nil, errs.ErrDuplicateKey.WrapMsg("group id existed", "groupID", exists)
}
now := time.Now()
ms := make([]*admindb.RegisterAddGroup, 0, len(req.GroupIDs))
for _, groupID := range req.GroupIDs {
ms = append(ms, &admindb.RegisterAddGroup{
GroupID: groupID,
CreateTime: now,
})
}
if err := o.Database.AddDefaultGroup(ctx, ms); err != nil {
return nil, err
}
return &admin.AddDefaultGroupResp{}, nil
}
func (o *adminServer) DelDefaultGroup(ctx context.Context, req *admin.DelDefaultGroupReq) (*admin.DelDefaultGroupResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.GroupIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("group ids is empty")
}
if datautil.Duplicate(req.GroupIDs) {
return nil, errs.ErrArgs.WrapMsg("group ids is duplicate")
}
exists, err := o.Database.FindDefaultGroup(ctx, req.GroupIDs)
if err != nil {
return nil, err
}
if ids := datautil.Single(req.GroupIDs, exists); len(ids) > 0 {
return nil, errs.ErrRecordNotFound.WrapMsg("group id not found", "groupID", ids)
}
now := time.Now()
ms := make([]*admindb.RegisterAddGroup, 0, len(req.GroupIDs))
for _, groupID := range req.GroupIDs {
ms = append(ms, &admindb.RegisterAddGroup{
GroupID: groupID,
CreateTime: now,
})
}
if err := o.Database.DelDefaultGroup(ctx, req.GroupIDs); err != nil {
return nil, err
}
return &admin.DelDefaultGroupResp{}, nil
}
func (o *adminServer) FindDefaultGroup(ctx context.Context, req *admin.FindDefaultGroupReq) (*admin.FindDefaultGroupResp, error) {
if _, _, err := mctx.Check(ctx); err != nil {
return nil, err
}
groupIDs, err := o.Database.FindDefaultGroup(ctx, nil)
if err != nil {
return nil, err
}
return &admin.FindDefaultGroupResp{GroupIDs: groupIDs}, nil
}
func (o *adminServer) SearchDefaultGroup(ctx context.Context, req *admin.SearchDefaultGroupReq) (*admin.SearchDefaultGroupResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
total, infos, err := o.Database.SearchDefaultGroup(ctx, req.Keyword, req.Pagination)
if err != nil {
return nil, err
}
return &admin.SearchDefaultGroupResp{Total: uint32(total), GroupIDs: datautil.Slice(infos, func(info *admindb.RegisterAddGroup) string { return info.GroupID })}, nil
}

View File

@@ -0,0 +1,102 @@
// 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 admin
import (
"context"
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/admin"
"github.com/openimsdk/tools/errs"
)
// ==================== 定时任务管理相关 RPC ====================
// GetScheduledTasks 获取定时任务列表(管理员接口,可查看所有任务)
func (o *adminServer) GetScheduledTasks(ctx context.Context, req *admin.GetScheduledTasksReq) (*admin.GetScheduledTasksResp, error) {
// 验证管理员权限
_, err := mctx.CheckAdmin(ctx)
if err != nil {
return nil, err
}
// 获取所有定时任务列表
total, tasks, err := o.ChatDatabase.GetAllScheduledTasks(ctx, req.Pagination)
if err != nil {
return nil, err
}
// 转换为响应格式
taskInfos := make([]*admin.ScheduledTaskInfo, 0, len(tasks))
for _, task := range tasks {
taskInfos = append(taskInfos, convertScheduledTaskToAdminProto(task))
}
return &admin.GetScheduledTasksResp{
Total: total,
Tasks: taskInfos,
}, nil
}
// DeleteScheduledTask 删除定时任务(管理员接口,可删除任何任务)
func (o *adminServer) DeleteScheduledTask(ctx context.Context, req *admin.DeleteScheduledTaskReq) (*admin.DeleteScheduledTaskResp, error) {
// 验证管理员权限
_, err := mctx.CheckAdmin(ctx)
if err != nil {
return nil, err
}
// 验证请求参数
if len(req.TaskIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("taskIDs is required")
}
// 删除任务
if err := o.ChatDatabase.DeleteScheduledTask(ctx, req.TaskIDs); err != nil {
return nil, err
}
return &admin.DeleteScheduledTaskResp{}, nil
}
// convertScheduledTaskToAdminProto 将数据库模型转换为 admin protobuf 消息
func convertScheduledTaskToAdminProto(task *chatdb.ScheduledTask) *admin.ScheduledTaskInfo {
messages := make([]*admin.ScheduledTaskMessage, 0, len(task.Messages))
for _, msg := range task.Messages {
messages = append(messages, &admin.ScheduledTaskMessage{
Type: msg.Type,
Content: msg.Content,
Thumbnail: msg.Thumbnail,
Duration: msg.Duration,
FileSize: msg.FileSize,
Width: msg.Width,
Height: msg.Height,
})
}
return &admin.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(),
}
}

110
internal/rpc/admin/start.go Normal file
View File

@@ -0,0 +1,110 @@
package admin
import (
"context"
"crypto/md5"
"encoding/hex"
"math/rand"
"time"
"git.imall.cloud/openim/chat/pkg/common/config"
"git.imall.cloud/openim/chat/pkg/common/constant"
"git.imall.cloud/openim/chat/pkg/common/db/database"
"git.imall.cloud/openim/chat/pkg/common/db/dbutil"
"git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/imapi"
"git.imall.cloud/openim/chat/pkg/common/tokenverify"
adminpb "git.imall.cloud/openim/chat/pkg/protocol/admin"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
chatClient "git.imall.cloud/openim/chat/pkg/rpclient/chat"
"github.com/openimsdk/tools/db/mongoutil"
"github.com/openimsdk/tools/db/redisutil"
"github.com/openimsdk/tools/discovery"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/mw"
"github.com/openimsdk/tools/utils/runtimeenv"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type Config struct {
RpcConfig config.Admin
RedisConfig config.Redis
MongodbConfig config.Mongo
Discovery config.Discovery
Share config.Share
RuntimeEnv string
}
func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryRegistry, server *grpc.Server) error {
config.RuntimeEnv = runtimeenv.PrintRuntimeEnvironment()
if len(config.Share.ChatAdmin) == 0 {
return errs.New("share chat admin not configured")
}
rand.Seed(time.Now().UnixNano())
rdb, err := redisutil.NewRedisClient(ctx, config.RedisConfig.Build())
if err != nil {
return err
}
mgocli, err := mongoutil.NewMongoDB(ctx, config.MongodbConfig.Build())
if err != nil {
return err
}
var srv adminServer
srv.Database, err = database.NewAdminDatabase(mgocli, rdb)
if err != nil {
return err
}
srv.ChatDatabase, err = database.NewChatDatabase(mgocli, rdb)
if err != nil {
return err
}
conn, err := client.GetConn(ctx, config.Discovery.RpcService.Chat, grpc.WithTransportCredentials(insecure.NewCredentials()), mw.GrpcClient())
if err != nil {
return err
}
srv.Chat = chatClient.NewChatClient(chat.NewChatClient(conn))
srv.Token = &tokenverify.Token{
Expires: time.Duration(config.RpcConfig.TokenPolicy.Expire) * time.Hour * 24,
Secret: config.RpcConfig.Secret,
}
srv.ImApiCaller = imapi.New(config.Share.OpenIM.ApiURL, config.Share.OpenIM.Secret, config.Share.OpenIM.AdminUserID)
if err := srv.initAdmin(ctx, config.Share.ChatAdmin, config.Share.OpenIM.AdminUserID); err != nil {
return err
}
adminpb.RegisterAdminServer(server, &srv)
return nil
}
type adminServer struct {
adminpb.UnimplementedAdminServer
Database database.AdminDatabaseInterface
ChatDatabase database.ChatDatabaseInterface
Chat *chatClient.ChatClient
Token *tokenverify.Token
ImApiCaller imapi.CallerInterface
}
func (o *adminServer) initAdmin(ctx context.Context, admins []string, imUserID string) error {
for _, account := range admins {
if _, err := o.Database.GetAdmin(ctx, account); err == nil {
continue
} else if !dbutil.IsDBNotFound(err) {
return err
}
sum := md5.Sum([]byte(account))
a := admin.Admin{
Account: account,
UserID: imUserID,
Password: hex.EncodeToString(sum[:]),
Level: constant.DefaultAdminLevel,
CreateTime: time.Now(),
}
if err := o.Database.AddAdminAccount(ctx, []*admin.Admin{&a}); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,439 @@
// 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 admin
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
chatdb "git.imall.cloud/openim/chat/pkg/common/db/table/chat"
"git.imall.cloud/openim/chat/pkg/common/mctx"
adminpb "git.imall.cloud/openim/chat/pkg/protocol/admin"
"github.com/openimsdk/tools/errs"
)
// ==================== 系统配置管理相关 RPC ====================
// convertValueToString 将任意类型的值转换为字符串(根据 ValueType
// 支持接收字符串、数字、布尔值、JSON对象
func convertValueToString(value interface{}, valueType int32) (string, error) {
if value == nil {
return "", errs.ErrArgs.WrapMsg("value cannot be nil")
}
switch valueType {
case chatdb.ConfigValueTypeString:
// 字符串类型:直接转换为字符串
switch v := value.(type) {
case string:
return v, nil
default:
// 其他类型转为字符串
return fmt.Sprintf("%v", v), nil
}
case chatdb.ConfigValueTypeNumber:
// 数字类型:转换为数字字符串
switch v := value.(type) {
case string:
// 验证是否为有效数字
if _, err := strconv.ParseFloat(v, 64); err != nil {
return "", errs.ErrArgs.WrapMsg("value must be a valid number")
}
return v, nil
case float64:
return strconv.FormatFloat(v, 'f', -1, 64), nil
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32), nil
case int:
return strconv.Itoa(v), nil
case int64:
return strconv.FormatInt(v, 10), nil
case int32:
return strconv.FormatInt(int64(v), 10), nil
default:
// 尝试转换为数字
if num, ok := v.(float64); ok {
return strconv.FormatFloat(num, 'f', -1, 64), nil
}
return "", errs.ErrArgs.WrapMsg("value must be a number")
}
case chatdb.ConfigValueTypeBool:
// 布尔类型:转换为 "true" 或 "false"
switch v := value.(type) {
case string:
// 验证是否为有效的布尔字符串
if _, err := strconv.ParseBool(v); err != nil {
return "", errs.ErrArgs.WrapMsg("value must be 'true' or 'false'")
}
return v, nil
case bool:
return strconv.FormatBool(v), nil
default:
return "", errs.ErrArgs.WrapMsg("value must be a boolean")
}
case chatdb.ConfigValueTypeJSON:
// JSON类型转换为 JSON 字符串
switch v := value.(type) {
case string:
// 验证是否为有效的 JSON
var js interface{}
if err := json.Unmarshal([]byte(v), &js); err != nil {
return "", errs.ErrArgs.WrapMsg("value must be a valid JSON string")
}
return v, nil
default:
// 将对象序列化为 JSON 字符串
jsonBytes, err := json.Marshal(v)
if err != nil {
return "", errs.ErrArgs.WrapMsg("value must be a valid JSON object")
}
return string(jsonBytes), nil
}
default:
return "", errs.ErrArgs.WrapMsg("invalid value type")
}
}
// convertValueFromString 将字符串值转换为对应类型(用于返回给前端)
func convertValueFromString(value string, valueType int32) (interface{}, error) {
switch valueType {
case chatdb.ConfigValueTypeString:
return value, nil
case chatdb.ConfigValueTypeNumber:
// 尝试解析为数字
if num, err := strconv.ParseFloat(value, 64); err == nil {
// 如果是整数,返回整数;否则返回浮点数
if num == float64(int64(num)) {
return int64(num), nil
}
return num, nil
}
return nil, errs.ErrArgs.WrapMsg("invalid number format")
case chatdb.ConfigValueTypeBool:
return strconv.ParseBool(value)
case chatdb.ConfigValueTypeJSON:
var js interface{}
if err := json.Unmarshal([]byte(value), &js); err != nil {
return nil, err
}
return js, nil
default:
return value, nil
}
}
// validateValueByType 根据 ValueType 验证 Value 的格式
func validateValueByType(value string, valueType int32) error {
switch valueType {
case chatdb.ConfigValueTypeString:
// 字符串类型:任何字符串都可以,无需验证
return nil
case chatdb.ConfigValueTypeNumber:
// 数字类型:必须是有效的数字字符串
if value == "" {
return errs.ErrArgs.WrapMsg("value cannot be empty for number type")
}
_, err := strconv.ParseFloat(value, 64)
if err != nil {
return errs.ErrArgs.WrapMsg("value must be a valid number string")
}
return nil
case chatdb.ConfigValueTypeBool:
// 布尔类型:必须是 "true" 或 "false"
if value == "" {
return errs.ErrArgs.WrapMsg("value cannot be empty for bool type")
}
_, err := strconv.ParseBool(value)
if err != nil {
return errs.ErrArgs.WrapMsg("value must be 'true' or 'false' for bool type")
}
return nil
case chatdb.ConfigValueTypeJSON:
// JSON类型必须是有效的 JSON 字符串
if value == "" {
return errs.ErrArgs.WrapMsg("value cannot be empty for JSON type")
}
var js interface{}
if err := json.Unmarshal([]byte(value), &js); err != nil {
return errs.ErrArgs.WrapMsg("value must be a valid JSON string")
}
return nil
default:
return errs.ErrArgs.WrapMsg("invalid value type")
}
}
// CreateSystemConfig 创建系统配置
func (o *adminServer) CreateSystemConfig(ctx context.Context, req *adminpb.CreateSystemConfigReq) (*adminpb.CreateSystemConfigResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 验证必填字段
if req.Key == "" {
return nil, errs.ErrArgs.WrapMsg("config key is required")
}
// 检查配置键是否已存在
_, err := o.ChatDatabase.GetSystemConfig(ctx, req.Key)
if err == nil {
return nil, errs.ErrDuplicateKey.WrapMsg("config key already exists")
}
// 创建配置对象
config := &chatdb.SystemConfig{
Key: req.Key,
Title: req.Title,
Value: req.Value,
ValueType: req.ValueType,
Description: req.Description,
Enabled: req.Enabled,
ShowInApp: req.ShowInApp,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
// 如果未设置值类型,默认为字符串类型
if config.ValueType == 0 {
config.ValueType = chatdb.ConfigValueTypeString
}
// 根据 ValueType 验证 Value 的格式
if err := validateValueByType(config.Value, config.ValueType); err != nil {
return nil, err
}
// 保存到数据库
if err := o.ChatDatabase.CreateSystemConfig(ctx, config); err != nil {
return nil, err
}
return &adminpb.CreateSystemConfigResp{}, nil
}
// GetSystemConfig 获取系统配置详情
func (o *adminServer) GetSystemConfig(ctx context.Context, req *adminpb.GetSystemConfigReq) (*adminpb.GetSystemConfigResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 获取配置
config, err := o.ChatDatabase.GetSystemConfig(ctx, req.Key)
if err != nil {
return nil, err
}
return &adminpb.GetSystemConfigResp{
Config: convertSystemConfigToProto(config),
}, nil
}
// GetAllSystemConfigs 获取所有系统配置(分页)
func (o *adminServer) GetAllSystemConfigs(ctx context.Context, req *adminpb.GetAllSystemConfigsReq) (*adminpb.GetAllSystemConfigsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 获取配置列表
total, configs, err := o.ChatDatabase.GetAllSystemConfigs(ctx, req.Pagination)
if err != nil {
return nil, err
}
// 转换为响应格式
configInfos := make([]*adminpb.SystemConfigInfo, 0, len(configs))
for _, config := range configs {
configInfos = append(configInfos, convertSystemConfigToProto(config))
}
return &adminpb.GetAllSystemConfigsResp{
Total: uint32(total),
List: configInfos,
}, nil
}
// UpdateSystemConfig 更新系统配置
func (o *adminServer) UpdateSystemConfig(ctx context.Context, req *adminpb.UpdateSystemConfigReq) (*adminpb.UpdateSystemConfigResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 验证配置是否存在
_, err := o.ChatDatabase.GetSystemConfig(ctx, req.Key)
if err != nil {
return nil, err
}
// 获取当前配置,用于验证
currentConfig, err := o.ChatDatabase.GetSystemConfig(ctx, req.Key)
if err != nil {
return nil, err
}
// 确定要使用的 ValueType如果更新了 ValueType使用新的否则使用当前的
newValueType := currentConfig.ValueType
if req.ValueType != nil {
newValueType = req.ValueType.Value
}
// 确定要使用的 Value如果更新了 Value使用新的否则使用当前的
newValue := currentConfig.Value
if req.Value != nil {
newValue = req.Value.Value
// 如果更新了 Value需要根据 ValueType 验证
if err := validateValueByType(newValue, newValueType); err != nil {
return nil, err
}
} else if req.ValueType != nil {
// 如果只更新了 ValueType需要验证当前 Value 是否符合新的 ValueType
if err := validateValueByType(currentConfig.Value, newValueType); err != nil {
return nil, err
}
}
// 构建更新数据
updateData := make(map[string]any)
if req.Title != nil {
updateData["title"] = req.Title.Value
}
if req.Value != nil {
updateData["value"] = req.Value.Value
}
if req.ValueType != nil {
updateData["value_type"] = req.ValueType.Value
}
if req.Description != nil {
updateData["description"] = req.Description.Value
}
if req.Enabled != nil {
updateData["enabled"] = req.Enabled.Value
}
if req.ShowInApp != nil {
updateData["show_in_app"] = req.ShowInApp.Value
}
// 更新配置
if err := o.ChatDatabase.UpdateSystemConfig(ctx, req.Key, updateData); err != nil {
return nil, err
}
return &adminpb.UpdateSystemConfigResp{}, nil
}
// UpdateSystemConfigValue 更新系统配置值
func (o *adminServer) UpdateSystemConfigValue(ctx context.Context, req *adminpb.UpdateSystemConfigValueReq) (*adminpb.UpdateSystemConfigValueResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 获取当前配置,用于获取 ValueType 进行验证
config, err := o.ChatDatabase.GetSystemConfig(ctx, req.Key)
if err != nil {
return nil, err
}
// 根据当前 ValueType 验证新 Value 的格式
if err := validateValueByType(req.Value, config.ValueType); err != nil {
return nil, err
}
// 更新配置值
if err := o.ChatDatabase.UpdateSystemConfigValue(ctx, req.Key, req.Value); err != nil {
return nil, err
}
return &adminpb.UpdateSystemConfigValueResp{}, nil
}
// UpdateSystemConfigEnabled 更新系统配置启用状态
func (o *adminServer) UpdateSystemConfigEnabled(ctx context.Context, req *adminpb.UpdateSystemConfigEnabledReq) (*adminpb.UpdateSystemConfigEnabledResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 更新启用状态
if err := o.ChatDatabase.UpdateSystemConfigEnabled(ctx, req.Key, req.Enabled); err != nil {
return nil, err
}
return &adminpb.UpdateSystemConfigEnabledResp{}, nil
}
// DeleteSystemConfig 删除系统配置
func (o *adminServer) DeleteSystemConfig(ctx context.Context, req *adminpb.DeleteSystemConfigReq) (*adminpb.DeleteSystemConfigResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 删除配置
if err := o.ChatDatabase.DeleteSystemConfig(ctx, req.Keys); err != nil {
return nil, err
}
return &adminpb.DeleteSystemConfigResp{}, nil
}
// GetEnabledSystemConfigs 获取所有已启用的配置
func (o *adminServer) GetEnabledSystemConfigs(ctx context.Context, req *adminpb.GetEnabledSystemConfigsReq) (*adminpb.GetEnabledSystemConfigsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 获取已启用的配置
configs, err := o.ChatDatabase.GetEnabledSystemConfigs(ctx)
if err != nil {
return nil, err
}
// 转换为响应格式
configInfos := make([]*adminpb.SystemConfigInfo, 0, len(configs))
for _, config := range configs {
configInfos = append(configInfos, convertSystemConfigToProto(config))
}
return &adminpb.GetEnabledSystemConfigsResp{
List: configInfos,
}, nil
}
// convertSystemConfigToProto 将数据库模型转换为 protobuf 消息
func convertSystemConfigToProto(config *chatdb.SystemConfig) *adminpb.SystemConfigInfo {
return &adminpb.SystemConfigInfo{
Key: config.Key,
Title: config.Title,
Value: config.Value,
ValueType: config.ValueType,
Description: config.Description,
Enabled: config.Enabled,
ShowInApp: config.ShowInApp,
CreateTime: config.CreateTime.UnixMilli(),
UpdateTime: config.UpdateTime.UnixMilli(),
}
}

View File

@@ -0,0 +1,79 @@
// 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 admin
import (
"context"
"github.com/redis/go-redis/v9"
"git.imall.cloud/openim/chat/pkg/eerrs"
adminpb "git.imall.cloud/openim/chat/pkg/protocol/admin"
"github.com/openimsdk/tools/log"
)
func (o *adminServer) CreateToken(ctx context.Context, req *adminpb.CreateTokenReq) (*adminpb.CreateTokenResp, error) {
token, expire, err := o.Token.CreateToken(req.UserID, req.UserType)
if err != nil {
return nil, err
}
err = o.Database.CacheToken(ctx, req.UserID, token, expire)
if err != nil {
return nil, err
}
return &adminpb.CreateTokenResp{
Token: token,
}, nil
}
func (o *adminServer) ParseToken(ctx context.Context, req *adminpb.ParseTokenReq) (*adminpb.ParseTokenResp, error) {
userID, userType, err := o.Token.GetToken(req.Token)
if err != nil {
return nil, err
}
m, err := o.Database.GetTokens(ctx, userID)
if err != nil && err != redis.Nil {
return nil, err
}
if len(m) == 0 {
return nil, eerrs.ErrTokenNotExist.Wrap()
}
if _, ok := m[req.Token]; !ok {
return nil, eerrs.ErrTokenNotExist.Wrap()
}
return &adminpb.ParseTokenResp{
UserID: userID,
UserType: userType,
}, nil
}
func (o *adminServer) GetUserToken(ctx context.Context, req *adminpb.GetUserTokenReq) (*adminpb.GetUserTokenResp, error) {
tokensMap, err := o.Database.GetTokens(ctx, req.UserID)
if err != nil {
return nil, err
}
return &adminpb.GetUserTokenResp{TokensMap: tokensMap}, nil
}
func (o *adminServer) InvalidateToken(ctx context.Context, req *adminpb.InvalidateTokenReq) (*adminpb.InvalidateTokenResp, error) {
err := o.Database.DeleteToken(ctx, req.UserID)
if err != nil && err != redis.Nil {
return nil, err
}
log.ZDebug(ctx, "delete token from redis", "userID", req.UserID)
return &adminpb.InvalidateTokenResp{}, nil
}

View File

@@ -0,0 +1,140 @@
// 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 admin
import (
"crypto/md5"
"encoding/hex"
"time"
"github.com/openimsdk/tools/errs"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
)
type Admin struct {
Account string `gorm:"column:account;primary_key;type:char(64)"`
Password string `gorm:"column:password;type:char(64)"`
FaceURL string `gorm:"column:face_url;type:char(64)"`
Nickname string `gorm:"column:nickname;type:char(64)"`
UserID string `gorm:"column:user_id;type:char(64)"` // openIM userID
Level int32 `gorm:"column:level;default:1" `
CreateTime time.Time `gorm:"column:create_time"`
}
func ToDBAdminUpdate(req *admin.AdminUpdateInfoReq) (map[string]any, error) {
update := make(map[string]any)
if req.Account != nil {
if req.Account.Value == "" {
return nil, errs.ErrArgs.WrapMsg("account is empty")
}
update["account"] = req.Account.Value
}
if req.Password != nil {
if req.Password.Value == "" {
return nil, errs.ErrArgs.WrapMsg("password is empty")
}
update["password"] = req.Password.Value
}
if req.FaceURL != nil {
update["face_url"] = req.FaceURL.Value
}
if req.Nickname != nil {
if req.Nickname.Value == "" {
return nil, errs.ErrArgs.WrapMsg("nickname is empty")
}
update["nickname"] = req.Nickname.Value
}
//if req.UserID != nil {
// update["user_id"] = req.UserID.Value
//}
if req.Level != nil {
update["level"] = req.Level.Value
}
if req.GoogleAuthKey != nil {
update["google_auth_key"] = req.GoogleAuthKey.Value
}
if req.OperationPassword != nil {
update["operation_password"] = req.OperationPassword.Value
}
if len(update) == 0 {
return nil, errs.ErrArgs.WrapMsg("no update info")
}
return update, nil
}
func ToDBAdminUpdatePassword(password string) (map[string]any, error) {
if password == "" {
return nil, errs.ErrArgs.WrapMsg("password is empty")
}
return map[string]any{"password": password}, nil
}
func ToDBAppletUpdate(req *admin.UpdateAppletReq) (map[string]any, error) {
update := make(map[string]any)
if req.Name != nil {
if req.Name.Value == "" {
return nil, errs.ErrArgs.WrapMsg("name is empty")
}
update["name"] = req.Name.Value
}
if req.AppID != nil {
if req.AppID.Value == "" {
return nil, errs.ErrArgs.WrapMsg("appID is empty")
}
update["app_id"] = req.AppID.Value
}
if req.Icon != nil {
update["icon"] = req.Icon.Value
}
if req.Url != nil {
if req.Url.Value == "" {
return nil, errs.ErrArgs.WrapMsg("url is empty")
}
update["url"] = req.Url.Value
}
if req.Md5 != nil {
if hash, _ := hex.DecodeString(req.Md5.Value); len(hash) != md5.Size {
return nil, errs.ErrArgs.WrapMsg("md5 is invalid")
}
update["md5"] = req.Md5.Value
}
if req.Size != nil {
if req.Size.Value <= 0 {
return nil, errs.ErrArgs.WrapMsg("size is invalid")
}
update["size"] = req.Size.Value
}
if req.Version != nil {
if req.Version.Value == "" {
return nil, errs.ErrArgs.WrapMsg("version is empty")
}
update["version"] = req.Version.Value
}
if req.Priority != nil {
update["priority"] = req.Priority.Value
}
if req.Status != nil {
update["status"] = req.Status.Value
}
if len(update) == 0 {
return nil, errs.ErrArgs.WrapMsg("no update info")
}
return update, nil
}
func ToDBInvitationRegisterUpdate(userID string) map[string]any {
return map[string]any{"user_id": userID}
}

147
internal/rpc/admin/user.go Normal file
View File

@@ -0,0 +1,147 @@
// 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 admin
import (
"context"
"strings"
"time"
"git.imall.cloud/openim/protocol/wrapperspb"
"github.com/openimsdk/tools/utils/datautil"
"git.imall.cloud/openim/chat/pkg/common/db/dbutil"
admindb "git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
"git.imall.cloud/openim/chat/pkg/protocol/chat"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/mcontext"
)
func (o *adminServer) CancellationUser(ctx context.Context, req *admin.CancellationUserReq) (*admin.CancellationUserResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
empty := wrapperspb.String("")
update := &chat.UpdateUserInfoReq{UserID: req.UserID, Account: empty, AreaCode: empty, PhoneNumber: empty, Email: empty}
if err := o.Chat.UpdateUser(ctx, update); err != nil {
return nil, err
}
return &admin.CancellationUserResp{}, nil
}
func (o *adminServer) BlockUser(ctx context.Context, req *admin.BlockUserReq) (*admin.BlockUserResp, error) {
_, err := mctx.CheckAdmin(ctx)
if err != nil {
return nil, err
}
_, err = o.Database.GetBlockInfo(ctx, req.UserID)
if err == nil {
return nil, errs.ErrArgs.WrapMsg("user already blocked")
} else if !dbutil.IsDBNotFound(err) {
return nil, err
}
t := &admindb.ForbiddenAccount{
UserID: req.UserID,
Reason: req.Reason,
OperatorUserID: mcontext.GetOpUserID(ctx),
CreateTime: time.Now(),
}
if err := o.Database.BlockUser(ctx, []*admindb.ForbiddenAccount{t}); err != nil {
return nil, err
}
return &admin.BlockUserResp{}, nil
}
func (o *adminServer) UnblockUser(ctx context.Context, req *admin.UnblockUserReq) (*admin.UnblockUserResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.UserIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("empty user id")
}
if datautil.Duplicate(req.UserIDs) {
return nil, errs.ErrArgs.WrapMsg("duplicate user id")
}
bs, err := o.Database.FindBlockInfo(ctx, req.UserIDs)
if err != nil {
return nil, err
}
if len(req.UserIDs) != len(bs) {
ids := datautil.Single(req.UserIDs, datautil.Slice(bs, func(info *admindb.ForbiddenAccount) string { return info.UserID }))
return nil, errs.ErrArgs.WrapMsg("user not blocked " + strings.Join(ids, ", "))
}
if err := o.Database.DelBlockUser(ctx, req.UserIDs); err != nil {
return nil, err
}
return &admin.UnblockUserResp{}, nil
}
func (o *adminServer) SearchBlockUser(ctx context.Context, req *admin.SearchBlockUserReq) (*admin.SearchBlockUserResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
total, infos, err := o.Database.SearchBlockUser(ctx, req.Keyword, req.Pagination)
if err != nil {
return nil, err
}
userIDs := datautil.Slice(infos, func(info *admindb.ForbiddenAccount) string { return info.UserID })
userMap, err := o.Chat.MapUserFullInfo(ctx, userIDs)
if err != nil {
return nil, err
}
users := make([]*admin.BlockUserInfo, 0, len(infos))
for _, info := range infos {
user := &admin.BlockUserInfo{
UserID: info.UserID,
Reason: info.Reason,
OpUserID: info.OperatorUserID,
CreateTime: info.CreateTime.UnixMilli(),
}
if userFull := userMap[info.UserID]; userFull != nil {
user.Account = userFull.Account
user.PhoneNumber = userFull.PhoneNumber
user.AreaCode = userFull.AreaCode
user.Email = userFull.Email
user.Nickname = userFull.Nickname
user.FaceURL = userFull.FaceURL
user.Gender = userFull.Gender
}
users = append(users, user)
}
return &admin.SearchBlockUserResp{Total: uint32(total), Users: users}, nil
}
func (o *adminServer) FindUserBlockInfo(ctx context.Context, req *admin.FindUserBlockInfoReq) (*admin.FindUserBlockInfoResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
list, err := o.Database.FindBlockUser(ctx, req.UserIDs)
if err != nil {
return nil, err
}
blocks := make([]*admin.BlockInfo, 0, len(list))
for _, info := range list {
blocks = append(blocks, &admin.BlockInfo{
UserID: info.UserID,
Reason: info.Reason,
OpUserID: info.OperatorUserID,
CreateTime: info.CreateTime.UnixMilli(),
})
}
return &admin.FindUserBlockInfoResp{Blocks: blocks}, nil
}

View File

@@ -0,0 +1,97 @@
// 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 admin
import (
"context"
"time"
"github.com/openimsdk/tools/utils/datautil"
admindb "git.imall.cloud/openim/chat/pkg/common/db/table/admin"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/admin"
"github.com/openimsdk/tools/errs"
)
func (o *adminServer) SearchUserIPLimitLogin(ctx context.Context, req *admin.SearchUserIPLimitLoginReq) (*admin.SearchUserIPLimitLoginResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
total, list, err := o.Database.SearchUserLimitLogin(ctx, req.Keyword, req.Pagination)
if err != nil {
return nil, err
}
userIDs := datautil.Slice(list, func(info *admindb.LimitUserLoginIP) string { return info.UserID })
userMap, err := o.Chat.MapUserPublicInfo(ctx, datautil.Distinct(userIDs))
if err != nil {
return nil, err
}
limits := make([]*admin.LimitUserLoginIP, 0, len(list))
for _, info := range list {
limits = append(limits, &admin.LimitUserLoginIP{
UserID: info.UserID,
Ip: info.IP,
CreateTime: info.CreateTime.UnixMilli(),
User: userMap[info.UserID],
})
}
return &admin.SearchUserIPLimitLoginResp{Total: uint32(total), Limits: limits}, nil
}
func (o *adminServer) AddUserIPLimitLogin(ctx context.Context, req *admin.AddUserIPLimitLoginReq) (*admin.AddUserIPLimitLoginResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.Limits) == 0 {
return nil, errs.ErrArgs.WrapMsg("limits is empty")
}
now := time.Now()
ts := make([]*admindb.LimitUserLoginIP, 0, len(req.Limits))
for _, limit := range req.Limits {
ts = append(ts, &admindb.LimitUserLoginIP{
UserID: limit.UserID,
IP: limit.Ip,
CreateTime: now,
})
}
if err := o.Database.AddUserLimitLogin(ctx, ts); err != nil {
return nil, err
}
return &admin.AddUserIPLimitLoginResp{}, nil
}
func (o *adminServer) DelUserIPLimitLogin(ctx context.Context, req *admin.DelUserIPLimitLoginReq) (*admin.DelUserIPLimitLoginResp, error) {
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
if len(req.Limits) == 0 {
return nil, errs.ErrArgs.WrapMsg("limits is empty")
}
ts := make([]*admindb.LimitUserLoginIP, 0, len(req.Limits))
for _, limit := range req.Limits {
if limit.UserID == "" || limit.Ip == "" {
return nil, errs.ErrArgs.WrapMsg("user_id or ip is empty")
}
ts = append(ts, &admindb.LimitUserLoginIP{
UserID: limit.UserID,
IP: limit.Ip,
})
}
if err := o.Database.DelUserLimitLogin(ctx, ts); err != nil {
return nil, err
}
return &admin.DelUserIPLimitLoginResp{}, nil
}

View File

@@ -0,0 +1,982 @@
// 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 admin
import (
"context"
"errors"
"time"
"git.imall.cloud/openim/chat/pkg/common/constant"
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/eerrs"
adminpb "git.imall.cloud/openim/chat/pkg/protocol/admin"
"git.imall.cloud/openim/protocol/sdkws"
"github.com/google/uuid"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"go.mongodb.org/mongo-driver/mongo"
)
// ==================== 钱包管理相关 RPC ====================
// GetUserWallet 获取用户钱包信息
func (o *adminServer) GetUserWallet(ctx context.Context, req *adminpb.GetUserWalletReq) (*adminpb.GetUserWalletResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 验证必填字段
if req.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("userID is required")
}
// 获取钱包信息
wallet, err := o.ChatDatabase.GetWallet(ctx, req.UserID)
if err != nil {
// 如果钱包不存在,返回默认值
if errors.Is(err, mongo.ErrNoDocuments) {
return &adminpb.GetUserWalletResp{
Wallet: &adminpb.WalletInfo{
UserID: req.UserID,
Balance: 0,
WithdrawAccount: "",
RealNameAuth: nil,
WithdrawReceiveAccount: "",
HasPaymentPassword: false,
CreateTime: 0,
UpdateTime: 0,
},
}, nil
}
return nil, err
}
// 转换实名认证信息
var realNameAuth *adminpb.RealNameAuthInfo
if wallet.RealNameAuth.IDCard != "" {
realNameAuth = &adminpb.RealNameAuthInfo{
IdCard: wallet.RealNameAuth.IDCard,
Name: wallet.RealNameAuth.Name,
IdCardPhotoFront: wallet.RealNameAuth.IDCardPhotoFront,
IdCardPhotoBack: wallet.RealNameAuth.IDCardPhotoBack,
AuditStatus: wallet.RealNameAuth.AuditStatus, // 审核状态0-未审核1-审核通过2-审核拒绝
}
}
return &adminpb.GetUserWalletResp{
Wallet: &adminpb.WalletInfo{
UserID: wallet.UserID,
Balance: wallet.Balance,
WithdrawAccount: wallet.WithdrawAccount,
RealNameAuth: realNameAuth,
WithdrawReceiveAccount: wallet.WithdrawReceiveAccount,
HasPaymentPassword: wallet.PaymentPassword != "",
CreateTime: wallet.CreateTime.UnixMilli(),
UpdateTime: wallet.UpdateTime.UnixMilli(),
},
}, nil
}
// UpdateUserWalletBalance 更新用户余额(后台充值/扣款)
// 使用原子操作防止并发问题
func (o *adminServer) UpdateUserWalletBalance(ctx context.Context, req *adminpb.UpdateUserWalletBalanceReq) (*adminpb.UpdateUserWalletBalanceResp, error) {
// 检查管理员权限
userID, err := mctx.CheckAdmin(ctx)
if err != nil {
return nil, err
}
// 获取管理员信息
adminUser, err := o.Database.GetAdminUserID(ctx, userID)
if err != nil {
return nil, err
}
// 检查是否为超级管理员level:100
if adminUser.Level != constant.AdvancedUserLevel {
return nil, errs.ErrNoPermission.WrapMsg("only super admin (level:100) can update wallet balance")
}
// 检查是否设置了操作密码
if adminUser.OperationPassword == "" {
return nil, errs.ErrNoPermission.WrapMsg("operation password must be set before updating wallet balance")
}
// 验证操作密码
if req.OperationPassword == "" {
return nil, eerrs.ErrPassword.WrapMsg("operation password is required")
}
if adminUser.OperationPassword != req.OperationPassword {
return nil, eerrs.ErrPassword.WrapMsg("operation password is incorrect")
}
// 验证必填字段
if req.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("userID is required")
}
if req.Amount == 0 {
return nil, errs.ErrArgs.WrapMsg("amount cannot be zero")
}
// 使用原子操作更新余额(防止并发问题)
// IncrementBalance 方法已经处理了钱包不存在的情况(通过 upsert
// 如果是扣款且余额不足,会返回明确的错误信息
beforeBalance, afterBalance, err := o.ChatDatabase.IncrementWalletBalance(ctx, req.UserID, req.Amount)
if err != nil {
// 所有错误都直接返回IncrementBalance 已经处理了各种情况:
// 1. 余额不足(扣款时):返回明确的错误信息
// 2. 钱包不存在且充值upsert 会自动创建
// 3. 钱包不存在且扣款:返回余额不足错误
return nil, err
}
// 创建余额变动记录
record := &chatdb.WalletBalanceRecord{
ID: uuid.New().String(),
UserID: req.UserID,
Amount: req.Amount,
Type: req.Type,
BeforeBalance: beforeBalance,
AfterBalance: afterBalance,
Remark: req.Remark,
CreateTime: time.Now(),
}
if err := o.ChatDatabase.CreateWalletBalanceRecord(ctx, record); err != nil {
// 余额变动记录创建失败,记录错误日志
// 注意:余额已经更新,但记录创建失败,这是一个严重的数据不一致问题
// 不返回错误,因为余额已经更新,返回错误会让调用方误以为余额未更新
log.ZError(ctx, "Failed to create wallet balance record", err,
"userID", req.UserID,
"amount", req.Amount,
"beforeBalance", beforeBalance,
"afterBalance", afterBalance,
"type", req.Type,
"remark", req.Remark)
}
return &adminpb.UpdateUserWalletBalanceResp{
Balance: afterBalance,
}, nil
}
// GetUserWalletBalanceRecords 获取用户余额变动记录列表
func (o *adminServer) GetUserWalletBalanceRecords(ctx context.Context, req *adminpb.GetUserWalletBalanceRecordsReq) (*adminpb.GetUserWalletBalanceRecordsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 验证必填字段
if req.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("userID is required")
}
// 获取余额变动记录列表
total, records, err := o.ChatDatabase.GetWalletBalanceRecords(ctx, req.UserID, req.Pagination)
if err != nil {
return nil, err
}
// 转换为响应格式
recordInfos := make([]*adminpb.WalletBalanceRecordInfo, 0, len(records))
for _, record := range records {
recordInfos = append(recordInfos, &adminpb.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 &adminpb.GetUserWalletBalanceRecordsResp{
Total: uint32(total),
List: recordInfos,
}, nil
}
// UpdateUserPaymentPassword 修改用户支付密码(后台)
func (o *adminServer) UpdateUserPaymentPassword(ctx context.Context, req *adminpb.UpdateUserPaymentPasswordReq) (*adminpb.UpdateUserPaymentPasswordResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 验证必填字段
if req.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("userID is required")
}
if req.PaymentPassword == "" {
return nil, errs.ErrArgs.WrapMsg("paymentPassword is required")
}
// 更新支付密码
if err := o.ChatDatabase.UpdateWalletPaymentPassword(ctx, req.UserID, req.PaymentPassword); err != nil {
return nil, err
}
return &adminpb.UpdateUserPaymentPasswordResp{}, nil
}
// SetUserWithdrawAccount 设置用户提款账号(后台)
func (o *adminServer) SetUserWithdrawAccount(ctx context.Context, req *adminpb.SetUserWithdrawAccountReq) (*adminpb.SetUserWithdrawAccountResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 验证必填字段
if req.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("userID is required")
}
if req.WithdrawAccount == "" {
return nil, errs.ErrArgs.WrapMsg("withdrawAccount is required")
}
// 更新提款账号
if err := o.ChatDatabase.UpdateWalletWithdrawAccount(ctx, req.UserID, req.WithdrawAccount); err != nil {
return nil, err
}
return &adminpb.SetUserWithdrawAccountResp{}, nil
}
// ==================== 提现管理相关 RPC操作 withdraw_applications====================
// GetWithdraw 获取提现申请详情
func (o *adminServer) GetWithdraw(ctx context.Context, req *adminpb.GetWithdrawReq) (*adminpb.GetWithdrawResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 验证必填字段
if req.WithdrawID == "" {
return nil, errs.ErrArgs.WrapMsg("applicationID is required")
}
// 获取提现申请
application, err := o.ChatDatabase.GetWithdrawApplication(ctx, req.WithdrawID)
if err != nil {
return nil, err
}
// 转换为响应格式
withdrawInfo := &adminpb.WithdrawInfo{
Id: application.ID,
UserID: application.UserID,
Amount: application.Amount,
WithdrawAccount: application.WithdrawAccount,
Status: application.Status,
AuditorID: application.AuditorID,
AuditTime: application.AuditTime.UnixMilli(),
AuditRemark: application.AuditRemark,
Ip: application.IP,
DeviceID: application.DeviceID,
Platform: application.Platform,
DeviceModel: application.DeviceModel,
DeviceBrand: application.DeviceBrand,
OsVersion: application.OSVersion,
AppVersion: application.AppVersion,
CreateTime: application.CreateTime.UnixMilli(),
UpdateTime: application.UpdateTime.UnixMilli(),
}
return &adminpb.GetWithdrawResp{
Withdraw: withdrawInfo,
}, nil
}
// GetUserWithdraws 获取用户的提现申请列表
func (o *adminServer) GetUserWithdraws(ctx context.Context, req *adminpb.GetUserWithdrawsReq) (*adminpb.GetUserWithdrawsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
// 验证必填字段
if req.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("userID is required")
}
// 获取用户的提现申请列表
total, applications, err := o.ChatDatabase.GetWithdrawApplicationsByUserID(ctx, req.UserID, req.Pagination)
if err != nil {
return nil, err
}
// 查询用户钱包信息(用于获取实名信息)
var wallet *chatdb.Wallet
wallet, err = o.ChatDatabase.GetWallet(ctx, req.UserID)
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
log.ZWarn(ctx, "Failed to get wallet for user withdraws", err, "userID", req.UserID)
}
// 转换为响应格式
withdrawInfos := make([]*adminpb.WithdrawInfo, 0, len(applications))
for _, application := range applications {
withdrawInfo := &adminpb.WithdrawInfo{
Id: application.ID,
UserID: application.UserID,
Amount: application.Amount,
WithdrawAccount: application.WithdrawAccount,
Status: application.Status,
AuditorID: application.AuditorID,
AuditTime: application.AuditTime.UnixMilli(),
AuditRemark: application.AuditRemark,
Ip: application.IP,
DeviceID: application.DeviceID,
Platform: application.Platform,
DeviceModel: application.DeviceModel,
DeviceBrand: application.DeviceBrand,
OsVersion: application.OSVersion,
AppVersion: application.AppVersion,
CreateTime: application.CreateTime.UnixMilli(),
UpdateTime: application.UpdateTime.UnixMilli(),
}
// 填充用户实名认证信息
if wallet != nil && wallet.RealNameAuth.IDCard != "" {
withdrawInfo.RealNameAuth = &adminpb.RealNameAuthInfo{
IdCard: wallet.RealNameAuth.IDCard,
Name: wallet.RealNameAuth.Name,
IdCardPhotoFront: wallet.RealNameAuth.IDCardPhotoFront,
IdCardPhotoBack: wallet.RealNameAuth.IDCardPhotoBack,
AuditStatus: wallet.RealNameAuth.AuditStatus, // 审核状态0-未审核1-审核通过2-审核拒绝
}
}
withdrawInfos = append(withdrawInfos, withdrawInfo)
}
return &adminpb.GetUserWithdrawsResp{
Total: uint32(total),
List: withdrawInfos,
}, nil
}
// GetWithdraws 获取提现申请列表(后台,支持按状态筛选)
func (o *adminServer) GetWithdraws(ctx context.Context, req *adminpb.GetWithdrawsReq) (*adminpb.GetWithdrawsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
var total int64
var applications []*chatdb.WithdrawApplication
var err error
// 如果指定了状态,按状态筛选;否则获取全部
if req.Status > 0 {
total, applications, err = o.ChatDatabase.GetWithdrawApplicationsByStatus(ctx, req.Status, req.Pagination)
} else {
total, applications, err = o.ChatDatabase.GetWithdrawApplicationsPage(ctx, req.Pagination)
}
if err != nil {
return nil, err
}
// 收集所有用户ID批量查询钱包信息用于获取实名信息
userIDs := make([]string, 0, len(applications))
userIDSet := make(map[string]bool)
for _, application := range applications {
if !userIDSet[application.UserID] {
userIDs = append(userIDs, application.UserID)
userIDSet[application.UserID] = true
}
}
// 批量查询钱包信息
walletMap := make(map[string]*chatdb.Wallet)
if len(userIDs) > 0 {
wallets, err := o.ChatDatabase.GetWalletsByUserIDs(ctx, userIDs)
if err != nil {
log.ZWarn(ctx, "Failed to get wallets for withdraw list", err, "userIDs", userIDs)
} else {
for _, wallet := range wallets {
walletMap[wallet.UserID] = wallet
}
}
}
// 转换为响应格式
withdrawInfos := make([]*adminpb.WithdrawInfo, 0, len(applications))
for _, application := range applications {
withdrawInfo := &adminpb.WithdrawInfo{
Id: application.ID,
UserID: application.UserID,
Amount: application.Amount,
WithdrawAccount: application.WithdrawAccount,
Status: application.Status,
AuditorID: application.AuditorID,
AuditTime: application.AuditTime.UnixMilli(),
AuditRemark: application.AuditRemark,
Ip: application.IP,
DeviceID: application.DeviceID,
Platform: application.Platform,
DeviceModel: application.DeviceModel,
DeviceBrand: application.DeviceBrand,
OsVersion: application.OSVersion,
AppVersion: application.AppVersion,
CreateTime: application.CreateTime.UnixMilli(),
UpdateTime: application.UpdateTime.UnixMilli(),
}
// 填充用户实名认证信息
if wallet, ok := walletMap[application.UserID]; ok && wallet.RealNameAuth.IDCard != "" {
withdrawInfo.RealNameAuth = &adminpb.RealNameAuthInfo{
IdCard: wallet.RealNameAuth.IDCard,
Name: wallet.RealNameAuth.Name,
IdCardPhotoFront: wallet.RealNameAuth.IDCardPhotoFront,
IdCardPhotoBack: wallet.RealNameAuth.IDCardPhotoBack,
AuditStatus: wallet.RealNameAuth.AuditStatus, // 审核状态0-未审核1-审核通过2-审核拒绝
}
}
withdrawInfos = append(withdrawInfos, withdrawInfo)
}
return &adminpb.GetWithdrawsResp{
Total: uint32(total),
List: withdrawInfos,
}, nil
}
// AuditWithdraw 批量审核提现申请
func (o *adminServer) AuditWithdraw(ctx context.Context, req *adminpb.AuditWithdrawReq) (*adminpb.AuditWithdrawResp, error) {
// 检查管理员权限
auditorID, err := mctx.CheckAdmin(ctx)
if err != nil {
return nil, err
}
// 验证必填字段
if len(req.WithdrawIDs) == 0 {
return nil, errs.ErrArgs.WrapMsg("withdrawIDs is required and cannot be empty")
}
if req.Status != chatdb.WithdrawApplicationStatusApproved && req.Status != chatdb.WithdrawApplicationStatusRejected {
return nil, errs.ErrArgs.WrapMsg("status must be 2 (approved) or 3 (rejected)")
}
var successCount uint32
var failCount uint32
var failedIDs []string
// 批量处理每个提现申请
for _, withdrawID := range req.WithdrawIDs {
// 获取提现申请
application, err := o.ChatDatabase.GetWithdrawApplication(ctx, withdrawID)
if err != nil {
failCount++
failedIDs = append(failedIDs, withdrawID)
log.ZWarn(ctx, "Get withdraw application failed", err, "withdrawID", withdrawID)
continue
}
// 检查提现申请状态:允许"待审核"和"已通过"状态的提现申请可以被审核
// 已通过的提现申请可以重新审核为拒绝
if application.Status != chatdb.WithdrawApplicationStatusPending && application.Status != chatdb.WithdrawApplicationStatusApproved {
failCount++
failedIDs = append(failedIDs, withdrawID)
log.ZWarn(ctx, "Withdraw application status is not pending or approved", nil, "withdrawID", withdrawID, "status", application.Status)
continue
}
// 更新提现申请状态
if err := o.ChatDatabase.UpdateWithdrawApplicationStatus(ctx, withdrawID, req.Status, auditorID, req.AuditRemark); err != nil {
failCount++
failedIDs = append(failedIDs, withdrawID)
log.ZWarn(ctx, "Update withdraw application status failed", err, "withdrawID", withdrawID)
continue
}
// 如果审核通过,不需要额外操作(因为用户申请时已经扣除了余额)
// 如果审核拒绝(包括从"待审核"改为"已拒绝",或从"已通过"改为"已拒绝"),需要将余额退回给用户
if req.Status == chatdb.WithdrawApplicationStatusRejected {
// 退回余额
beforeBalance, afterBalance, err := o.ChatDatabase.IncrementWalletBalance(ctx, application.UserID, application.Amount)
if err != nil {
// 记录错误但不影响审核状态更新
log.ZError(ctx, "Refund balance failed", err, "withdrawID", withdrawID, "userID", application.UserID, "amount", application.Amount)
} else {
// 创建余额变动记录(退款)
record := &chatdb.WalletBalanceRecord{
ID: uuid.New().String(),
UserID: application.UserID,
Amount: application.Amount,
Type: 4, // 4-退款
BeforeBalance: beforeBalance,
AfterBalance: afterBalance,
Remark: "提现审核拒绝,退回余额",
CreateTime: time.Now(),
}
if err := o.ChatDatabase.CreateWalletBalanceRecord(ctx, record); err != nil {
log.ZWarn(ctx, "Create wallet balance record failed", err, "withdrawID", withdrawID)
}
}
}
successCount++
}
return &adminpb.AuditWithdrawResp{
SuccessCount: successCount,
FailCount: failCount,
FailedIDs: failedIDs,
}, nil
}
// BatchUpdateWalletBalance 批量更新用户余额
func (o *adminServer) BatchUpdateWalletBalance(ctx context.Context, req *adminpb.BatchUpdateWalletBalanceReq) (*adminpb.BatchUpdateWalletBalanceResp, error) {
// 检查管理员权限
adminID, err := mctx.CheckAdmin(ctx)
if err != nil {
return nil, err
}
// 获取管理员信息
adminUser, err := o.Database.GetAdminUserID(ctx, adminID)
if err != nil {
return nil, err
}
// 检查是否为超级管理员level:100
if adminUser.Level != constant.AdvancedUserLevel {
return nil, errs.ErrNoPermission.WrapMsg("only super admin (level:100) can batch update wallet balance")
}
// 检查是否设置了操作密码
if adminUser.OperationPassword == "" {
return nil, errs.ErrNoPermission.WrapMsg("operation password must be set before batch updating wallet balance")
}
// 验证操作密码
if req.OperationPassword == "" {
return nil, eerrs.ErrPassword.WrapMsg("operation password is required")
}
if adminUser.OperationPassword != req.OperationPassword {
return nil, eerrs.ErrPassword.WrapMsg("operation password is incorrect")
}
// 验证必填字段
if len(req.Users) == 0 {
return nil, errs.ErrArgs.WrapMsg("users list cannot be empty")
}
// 验证默认操作类型
defaultOperation := req.Operation
if defaultOperation == "" {
defaultOperation = "add" // 默认为增加
}
if defaultOperation != "set" && defaultOperation != "add" && defaultOperation != "subtract" {
return nil, errs.ErrArgs.WrapMsg("default operation must be one of: set, add, subtract")
}
var results []*adminpb.BatchUpdateResultItem
var successCount uint32
var failedCount uint32
// 批量处理每个用户
for _, userItem := range req.Users {
result := &adminpb.BatchUpdateResultItem{
UserID: userItem.UserID,
PhoneNumber: userItem.PhoneNumber,
Account: userItem.Account,
Remark: userItem.Remark,
}
// 1. 根据提供的标识符查找用户
var targetUserID string
if userItem.UserID != "" {
// 直接使用 userID
targetUserID = userItem.UserID
} else if userItem.PhoneNumber != "" {
// 通过手机号查找用户(假设区号为空或默认)
attr, err := o.ChatDatabase.TakeAttributeByPhone(ctx, "", userItem.PhoneNumber)
if err != nil {
result.Success = false
result.Message = "user not found by phone number"
results = append(results, result)
failedCount++
continue
}
targetUserID = attr.UserID
result.UserID = targetUserID
} else if userItem.Account != "" {
// 通过账号查找用户
attr, err := o.ChatDatabase.TakeAttributeByAccount(ctx, userItem.Account)
if err != nil {
result.Success = false
result.Message = "user not found by account"
results = append(results, result)
failedCount++
continue
}
targetUserID = attr.UserID
result.UserID = targetUserID
} else {
result.Success = false
result.Message = "at least one of userID, phoneNumber, or account must be provided"
results = append(results, result)
failedCount++
continue
}
// 2. 确定使用的金额和操作类型
amount := userItem.Amount
if amount == 0 {
amount = req.Amount
}
operation := userItem.Operation
if operation == "" {
operation = defaultOperation
}
if operation != "set" && operation != "add" && operation != "subtract" {
result.Success = false
result.Message = "operation must be one of: set, add, subtract"
results = append(results, result)
failedCount++
continue
}
// 3. 获取当前余额
wallet, err := o.ChatDatabase.GetWallet(ctx, targetUserID)
var oldBalance int64
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
oldBalance = 0
} else {
result.Success = false
result.Message = "failed to get wallet: " + err.Error()
results = append(results, result)
failedCount++
continue
}
} else {
oldBalance = wallet.Balance
}
result.OldBalance = oldBalance
// 4. 根据操作类型计算新余额和变动金额
var newBalance int64
var incrementAmount int64
var balanceChangeType int32 = 99 // 99-其他(后台批量操作)
switch operation {
case "set":
newBalance = amount
incrementAmount = amount - oldBalance
case "add":
incrementAmount = amount
newBalance = oldBalance + amount
case "subtract":
incrementAmount = -amount
newBalance = oldBalance - amount
}
// 5. 检查余额是否会变为负数
if newBalance < 0 {
result.Success = false
result.Message = "insufficient balance: cannot be negative"
result.NewBalance = oldBalance
results = append(results, result)
failedCount++
continue
}
// 6. 更新余额
beforeBalance, afterBalance, err := o.ChatDatabase.IncrementWalletBalance(ctx, targetUserID, incrementAmount)
if err != nil {
result.Success = false
result.Message = "failed to update balance: " + err.Error()
result.NewBalance = oldBalance
results = append(results, result)
failedCount++
continue
}
result.OldBalance = beforeBalance
result.NewBalance = afterBalance
// 7. 创建余额变动记录
record := &chatdb.WalletBalanceRecord{
ID: uuid.New().String(),
UserID: targetUserID,
Amount: incrementAmount,
Type: balanceChangeType,
BeforeBalance: beforeBalance,
AfterBalance: afterBalance,
Remark: userItem.Remark,
CreateTime: time.Now(),
}
if err := o.ChatDatabase.CreateWalletBalanceRecord(ctx, record); err != nil {
log.ZWarn(ctx, "Create wallet balance record failed", err, "userID", targetUserID)
}
result.Success = true
result.Message = "success"
results = append(results, result)
successCount++
}
return &adminpb.BatchUpdateWalletBalanceResp{
Total: uint32(len(req.Users)),
Success: successCount,
Failed: failedCount,
Results: results,
}, nil
}
// GetWallets 获取钱包列表
func (o *adminServer) GetWallets(ctx context.Context, req *adminpb.GetWalletsReq) (*adminpb.GetWalletsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
var total int64
var wallets []*chatdb.Wallet
var err error
// 如果提供了查询条件,先查找用户
if req.UserID != "" || req.PhoneNumber != "" || req.Account != "" {
var userIDs []string
if req.UserID != "" {
// 直接使用 userID
userIDs = []string{req.UserID}
} else if req.PhoneNumber != "" {
// 通过手机号模糊查询用户
_, attributes, err := o.ChatDatabase.SearchUser(ctx, req.PhoneNumber, nil, nil, &sdkws.RequestPagination{PageNumber: 1, ShowNumber: 1000})
if err != nil {
return nil, err
}
for _, attr := range attributes {
userIDs = append(userIDs, attr.UserID)
}
} else if req.Account != "" {
// 通过账号模糊查询用户
_, attributes, err := o.ChatDatabase.SearchUser(ctx, req.Account, nil, nil, &sdkws.RequestPagination{PageNumber: 1, ShowNumber: 1000})
if err != nil {
return nil, err
}
for _, attr := range attributes {
userIDs = append(userIDs, attr.UserID)
}
}
// 根据 userIDs 查询钱包
if len(userIDs) > 0 {
wallets, err = o.ChatDatabase.GetWalletsByUserIDs(ctx, userIDs)
if err != nil {
return nil, err
}
total = int64(len(wallets))
} else {
total = 0
wallets = []*chatdb.Wallet{}
}
} else {
// 没有查询条件,获取所有钱包(分页)
total, wallets, err = o.ChatDatabase.GetWalletsPage(ctx, req.Pagination)
if err != nil {
return nil, err
}
}
// 提取所有 userIDs
userIDs := make([]string, 0, len(wallets))
for _, wallet := range wallets {
userIDs = append(userIDs, wallet.UserID)
}
// 批量获取用户属性(昵称、头像等)
userAttrMap := make(map[string]*chatdb.Attribute)
if len(userIDs) > 0 {
attributes, err := o.ChatDatabase.FindAttribute(ctx, userIDs)
if err != nil {
log.ZWarn(ctx, "Find user attributes failed", err, "userIDs", userIDs)
} else {
for _, attr := range attributes {
userAttrMap[attr.UserID] = attr
}
}
}
// 转换为响应格式
walletInfos := make([]*adminpb.WalletListItemInfo, 0, len(wallets))
for _, wallet := range wallets {
info := &adminpb.WalletListItemInfo{
UserID: wallet.UserID,
Balance: wallet.Balance,
CreateTime: wallet.CreateTime.UnixMilli(),
UpdateTime: wallet.UpdateTime.UnixMilli(),
}
// 填充用户昵称和头像
if attr, ok := userAttrMap[wallet.UserID]; ok {
info.Nickname = attr.Nickname
info.FaceURL = attr.FaceURL
}
walletInfos = append(walletInfos, info)
}
return &adminpb.GetWalletsResp{
Total: uint32(total),
Wallets: walletInfos,
}, nil
}
// GetRealNameAuths 获取实名认证列表(支持按审核状态筛选)
func (o *adminServer) GetRealNameAuths(ctx context.Context, req *adminpb.GetRealNameAuthsReq) (*adminpb.GetRealNameAuthsResp, error) {
// 检查管理员权限
if _, err := mctx.CheckAdmin(ctx); err != nil {
return nil, err
}
var total int64
var wallets []*chatdb.Wallet
var err error
// 查询逻辑:过滤身份证号不为空的(已完成实名认证)
// auditStatus: 0-待审核1-审核通过2-审核拒绝,<0 表示不过滤状态(全部)
// userID: 用户ID搜索可选为空时不过滤
total, wallets, err = o.ChatDatabase.GetWalletsPageByRealNameAuthAuditStatus(ctx, req.AuditStatus, req.UserID, req.Pagination)
if err != nil {
return nil, err
}
// 提取所有 userIDs
userIDs := make([]string, 0, len(wallets))
for _, wallet := range wallets {
userIDs = append(userIDs, wallet.UserID)
}
// 批量获取用户属性(昵称、头像等)
userAttrMap := make(map[string]*chatdb.Attribute)
if len(userIDs) > 0 {
attributes, err := o.ChatDatabase.FindAttribute(ctx, userIDs)
if err != nil {
log.ZWarn(ctx, "Find user attributes failed", err, "userIDs", userIDs)
} else {
for _, attr := range attributes {
userAttrMap[attr.UserID] = attr
}
}
}
// 转换为响应格式
authInfos := make([]*adminpb.RealNameAuthListItemInfo, 0, len(wallets))
for _, wallet := range wallets {
// 注意:数据库查询已经过滤了身份证号不为空的记录,这里不需要再次检查
// 如果在这里再次过滤,会导致返回数量不一致
// 处理创建时间:如果为零值(负数时间戳),使用更新时间或当前时间
createTime := wallet.CreateTime
if createTime.IsZero() || createTime.UnixMilli() < 0 {
if !wallet.UpdateTime.IsZero() {
createTime = wallet.UpdateTime
} else {
createTime = time.Now()
}
}
// 处理更新时间:如果为零值(负数时间戳),使用当前时间
updateTime := wallet.UpdateTime
if updateTime.IsZero() || updateTime.UnixMilli() < 0 {
updateTime = time.Now()
}
info := &adminpb.RealNameAuthListItemInfo{
UserID: wallet.UserID,
IdCard: wallet.RealNameAuth.IDCard,
Name: wallet.RealNameAuth.Name,
IdCardPhotoFront: wallet.RealNameAuth.IDCardPhotoFront,
IdCardPhotoBack: wallet.RealNameAuth.IDCardPhotoBack,
AuditStatus: wallet.RealNameAuth.AuditStatus,
CreateTime: createTime.UnixMilli(),
UpdateTime: updateTime.UnixMilli(),
}
// 填充用户昵称和头像
if attr, ok := userAttrMap[wallet.UserID]; ok {
info.Nickname = attr.Nickname
info.FaceURL = attr.FaceURL
}
authInfos = append(authInfos, info)
}
// 注意:不在应用层排序,因为数据库查询时已经按 create_time 倒序排序并分页
// 如果在应用层重新排序,会导致分页结果不准确
return &adminpb.GetRealNameAuthsResp{
Total: uint32(total),
List: authInfos,
}, nil
}
// AuditRealNameAuth 审核实名认证(通过/拒绝)
func (o *adminServer) AuditRealNameAuth(ctx context.Context, req *adminpb.AuditRealNameAuthReq) (*adminpb.AuditRealNameAuthResp, error) {
// 检查管理员权限
auditorID, err := mctx.CheckAdmin(ctx)
if err != nil {
return nil, err
}
// 验证必填字段
if req.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("userID is required")
}
if req.AuditStatus != 1 && req.AuditStatus != 2 {
return nil, errs.ErrArgs.WrapMsg("auditStatus must be 1 (approved) or 2 (rejected)")
}
// 获取钱包信息
wallet, err := o.ChatDatabase.GetWallet(ctx, req.UserID)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, errs.ErrArgs.WrapMsg("wallet not found")
}
return nil, err
}
// 检查是否已完成实名认证
if wallet.RealNameAuth.IDCard == "" || wallet.RealNameAuth.Name == "" {
return nil, errs.ErrArgs.WrapMsg("user has not completed real name authentication")
}
// 更新审核状态
wallet.RealNameAuth.AuditStatus = req.AuditStatus
if err := o.ChatDatabase.UpdateWalletRealNameAuth(ctx, req.UserID, wallet.RealNameAuth); err != nil {
return nil, errs.WrapMsg(err, "failed to update real name auth audit status")
}
log.ZInfo(ctx, "Real name auth audited", "userID", req.UserID, "auditorID", auditorID, "auditStatus", req.AuditStatus, "auditRemark", req.AuditRemark)
return &adminpb.AuditRealNameAuthResp{}, nil
}

153
internal/rpc/bot/agent.go Normal file
View File

@@ -0,0 +1,153 @@
package bot
import (
"context"
"crypto/rand"
"time"
"git.imall.cloud/openim/chat/pkg/common/constant"
"git.imall.cloud/openim/chat/pkg/common/convert"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/bot"
pbconstant "git.imall.cloud/openim/protocol/constant"
"git.imall.cloud/openim/protocol/sdkws"
"git.imall.cloud/openim/protocol/user"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/utils/datautil"
)
func (b *botSvr) CreateAgent(ctx context.Context, req *bot.CreateAgentReq) (*bot.CreateAgentResp, error) {
if req.Agent == nil {
return nil, errs.ErrArgs.WrapMsg("req.Agent is nil")
}
now := time.Now()
imToken, err := b.imCaller.ImAdminTokenWithDefaultAdmin(ctx)
if err != nil {
return nil, err
}
ctx = mctx.WithApiToken(ctx, imToken)
if req.Agent.UserID != "" {
req.Agent.UserID = constant.AgentUserIDPrefix + req.Agent.UserID
users, err := b.imCaller.GetUsersInfo(ctx, []string{req.Agent.UserID})
if err != nil {
return nil, err
}
if len(users) > 0 {
return nil, errs.ErrDuplicateKey.WrapMsg("agent userID already exists")
}
} else {
randUserIDs := make([]string, 5)
for i := range randUserIDs {
randUserIDs[i] = constant.AgentUserIDPrefix + genID(10)
}
users, err := b.imCaller.GetUsersInfo(ctx, randUserIDs)
if err != nil {
return nil, err
}
if len(users) == len(randUserIDs) {
return nil, errs.ErrDuplicateKey.WrapMsg("gen agent userID already exists, please try again")
}
userIDs := datautil.Batch(func(u *sdkws.UserInfo) string { return u.UserID }, users)
for _, uid := range randUserIDs {
if datautil.Contain(uid, userIDs...) {
continue
}
req.Agent.UserID = uid
break
}
}
if err := b.imCaller.AddNotificationAccount(ctx, &user.AddNotificationAccountReq{
UserID: req.Agent.UserID,
NickName: req.Agent.Nickname,
FaceURL: req.Agent.FaceURL,
AppMangerLevel: pbconstant.AppRobotAdmin,
}); err != nil {
return nil, err
}
dbagent := convert.PB2DBAgent(req.Agent)
dbagent.CreateTime = now
err = b.database.CreateAgent(ctx, dbagent)
if err != nil {
return nil, err
}
return &bot.CreateAgentResp{}, nil
}
func (b *botSvr) UpdateAgent(ctx context.Context, req *bot.UpdateAgentReq) (*bot.UpdateAgentResp, error) {
if _, err := b.database.TakeAgent(ctx, req.UserID); err != nil {
return nil, errs.ErrArgs.Wrap()
}
if req.FaceURL != nil || req.Nickname != nil {
imReq := &user.UpdateNotificationAccountInfoReq{
UserID: req.UserID,
}
if req.Nickname != nil {
imReq.NickName = *req.Nickname
}
if req.FaceURL != nil {
imReq.FaceURL = *req.FaceURL
}
imToken, err := b.imCaller.ImAdminTokenWithDefaultAdmin(ctx)
if err != nil {
return nil, err
}
ctx = mctx.WithApiToken(ctx, imToken)
err = b.imCaller.UpdateNotificationAccount(ctx, imReq)
if err != nil {
return nil, err
}
}
update := ToDBAgentUpdate(req)
err := b.database.UpdateAgent(ctx, req.UserID, update)
if err != nil {
return nil, err
}
return &bot.UpdateAgentResp{}, nil
}
func (b *botSvr) PageFindAgent(ctx context.Context, req *bot.PageFindAgentReq) (*bot.PageFindAgentResp, error) {
total, agents, err := b.database.PageAgents(ctx, req.UserIDs, req.Pagination)
if err != nil {
return nil, err
}
//_, userType, err := mctx.Check(ctx)
//if err != nil {
// return nil, err
//}
//if userType != constant.AdminUser {
for i := range agents {
agents[i].Key = ""
}
//}
return &bot.PageFindAgentResp{
Total: total,
Agents: convert.BatchDB2PBAgent(agents),
}, nil
}
func (b *botSvr) DeleteAgent(ctx context.Context, req *bot.DeleteAgentReq) (*bot.DeleteAgentResp, error) {
err := b.database.DeleteAgents(ctx, req.UserIDs)
if err != nil {
return nil, err
}
return &bot.DeleteAgentResp{}, nil
}
func genID(l int) string {
data := make([]byte, l)
_, _ = rand.Read(data)
chars := []byte("0123456789")
for i := 0; i < len(data); i++ {
if i == 0 {
data[i] = chars[1:][data[i]%9]
} else {
data[i] = chars[data[i]%10]
}
}
return string(data)
}

92
internal/rpc/bot/send.go Normal file
View File

@@ -0,0 +1,92 @@
package bot
import (
"context"
"encoding/json"
"time"
"git.imall.cloud/openim/chat/pkg/botstruct"
"git.imall.cloud/openim/chat/pkg/common/imapi"
"git.imall.cloud/openim/chat/pkg/common/mctx"
"git.imall.cloud/openim/chat/pkg/protocol/bot"
"git.imall.cloud/openim/protocol/constant"
"github.com/openimsdk/tools/errs"
"github.com/sashabaranov/go-openai"
)
func (b *botSvr) SendBotMessage(ctx context.Context, req *bot.SendBotMessageReq) (*bot.SendBotMessageResp, error) {
agent, err := b.database.TakeAgent(ctx, req.AgentID)
if err != nil {
return nil, errs.ErrArgs.WrapMsg("agent not found")
}
//convRespID, err := b.database.TakeConversationRespID(ctx, req.ConversationID, req.AgentID)
//if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
// return nil, err
//}
//var respID string
//if convRespID != nil {
// respID = convRespID.PreviousResponseID
//}
aiCfg := openai.DefaultConfig(agent.Key)
aiCfg.BaseURL = agent.Url
aiCfg.HTTPClient = b.httpClient
client := openai.NewClientWithConfig(aiCfg)
aiReq := openai.ChatCompletionRequest{
Model: agent.Model,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: agent.Prompts,
},
{
Role: openai.ChatMessageRoleUser,
Content: req.Content,
},
},
}
aiCtx, cancel := context.WithTimeout(ctx, time.Duration(b.timeout)*time.Second)
defer cancel()
completion, err := client.CreateChatCompletion(aiCtx, aiReq)
if err != nil {
return nil, errs.Wrap(err)
}
imToken, err := b.imCaller.ImAdminTokenWithDefaultAdmin(ctx)
if err != nil {
return nil, err
}
ctx = mctx.WithApiToken(ctx, imToken)
content := "no response"
if len(completion.Choices) > 0 {
content = completion.Choices[0].Message.Content
}
err = b.imCaller.SendSimpleMsg(ctx, &imapi.SendSingleMsgReq{
SendID: agent.UserID,
Content: content,
}, req.Key)
if err != nil {
return nil, err
}
//err = b.database.UpdateConversationRespID(ctx, req.ConversationID, agent.UserID, ToDBConversationRespIDUpdate(completion.ID))
//if err != nil {
// return nil, err
//}
return &bot.SendBotMessageResp{}, nil
}
func getContent(contentType int32, content string) (string, error) {
switch contentType {
case constant.Text:
var elem botstruct.TextElem
err := json.Unmarshal([]byte(content), &elem)
if err != nil {
return "", errs.ErrArgs.WrapMsg(err.Error())
}
return elem.Content, nil
default:
return "", errs.New("un support contentType").Wrap()
}
}

53
internal/rpc/bot/start.go Normal file
View File

@@ -0,0 +1,53 @@
package bot
import (
"context"
"net/http"
"time"
"git.imall.cloud/openim/chat/pkg/common/config"
"git.imall.cloud/openim/chat/pkg/common/db/database"
"git.imall.cloud/openim/chat/pkg/common/imapi"
"git.imall.cloud/openim/chat/pkg/protocol/bot"
"github.com/openimsdk/tools/db/mongoutil"
"github.com/openimsdk/tools/discovery"
"google.golang.org/grpc"
)
type Config struct {
RpcConfig config.Bot
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 {
mgocli, err := mongoutil.NewMongoDB(ctx, config.MongodbConfig.Build())
if err != nil {
return err
}
var srv botSvr
srv.database, err = database.NewBotDatabase(mgocli)
if err != nil {
return err
}
srv.timeout = config.RpcConfig.Timeout
srv.httpClient = &http.Client{
Timeout: time.Duration(config.RpcConfig.Timeout) * time.Second,
}
im := imapi.New(config.Share.OpenIM.ApiURL, config.Share.OpenIM.Secret, config.Share.OpenIM.AdminUserID)
srv.imCaller = im
bot.RegisterBotServer(server, &srv)
return nil
}
type botSvr struct {
bot.UnimplementedBotServer
database database.BotDatabase
httpClient *http.Client
timeout int
imCaller imapi.CallerInterface
//Admin *chatClient.AdminClient
}

View File

@@ -0,0 +1,37 @@
package bot
import "git.imall.cloud/openim/chat/pkg/protocol/bot"
func ToDBAgentUpdate(req *bot.UpdateAgentReq) map[string]any {
update := make(map[string]any)
if req.Key != nil {
update["key"] = req.Key
}
if req.Prompts != nil {
update["prompts"] = req.Prompts
}
if req.Model != nil {
update["model"] = req.Model
}
if req.FaceURL != nil {
update["face_url"] = req.FaceURL
}
if req.Nickname != nil {
update["nick_name"] = req.Nickname
}
if req.Identity != nil {
update["identity"] = req.Identity
}
if req.Url != nil {
update["url"] = req.Url
}
return update
}
func ToDBConversationRespIDUpdate(respID string) map[string]any {
update := map[string]any{
"previous_response_id": respID,
}
return update
}

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
}