复制项目

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
}