@ -122,6 +122,17 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
- binary: openim-rpc-captcha
|
||||
id: openim-rpc-captcha
|
||||
main: ./cmd/openim-rpc/openim-rpc-captcha/main.go
|
||||
goos:
|
||||
- darwin
|
||||
- windows
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
- binary: openim-rpc-conversation
|
||||
id: openim-rpc-conversation
|
||||
main: ./cmd/openim-rpc/openim-rpc-conversation/main.go
|
||||
@ -355,6 +366,7 @@ nfpms:
|
||||
- openim-msgtransfer
|
||||
- openim-push
|
||||
- openim-rpc-auth
|
||||
- openim-rpc-captcha
|
||||
- openim-rpc-conversation
|
||||
- openim-rpc-friend
|
||||
- openim-rpc-group
|
||||
|
||||
12
cmd/openim-rpc/openim-rpc-captcha/main.go
Normal file
@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/cmd"
|
||||
"github.com/openimsdk/tools/system/program"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cmd.NewCaptchaRpcCmd().Exec(); err != nil {
|
||||
program.ExitWithError(err)
|
||||
}
|
||||
}
|
||||
12
config/openim-rpc-captcha.yml
Normal file
@ -0,0 +1,12 @@
|
||||
rpc:
|
||||
registerIP: ""
|
||||
listenIP: 0.0.0.0
|
||||
autoSetPorts: false
|
||||
ports: [10520]
|
||||
|
||||
prometheus:
|
||||
enable: false
|
||||
ports: [12520]
|
||||
|
||||
verifyPadding: 8
|
||||
expireSeconds: 120
|
||||
@ -9,6 +9,7 @@ rpcRegisterName:
|
||||
auth: auth
|
||||
conversation: conversation
|
||||
third: third
|
||||
captcha: captcha
|
||||
|
||||
imAdminUserID: [ imAdmin ]
|
||||
|
||||
|
||||
6
go.mod
@ -104,6 +104,7 @@ require (
|
||||
github.com/go-openapi/swag v0.22.4 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-zookeeper/zk v1.0.3 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
@ -173,6 +174,7 @@ require (
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/wenlng/go-captcha/v2 v2.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
@ -193,7 +195,7 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/image v0.16.0 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/oauth2 v0.25.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
@ -228,3 +230,5 @@ require (
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/openimsdk/protocol => ./protocol
|
||||
|
||||
6
go.sum
@ -178,6 +178,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
@ -448,6 +450,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/wenlng/go-captcha/v2 v2.0.5 h1:+1FpVwJZmLCqEHxOt+HvpUArFGo107nRxOeRVHkZhTc=
|
||||
github.com/wenlng/go-captcha/v2 v2.0.5/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
@ -516,6 +520,8 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
|
||||
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
|
||||
49
internal/api/captcha.go
Normal file
@ -0,0 +1,49 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
pbcaptcha "github.com/openimsdk/protocol/captcha"
|
||||
"github.com/openimsdk/tools/a2r"
|
||||
"github.com/openimsdk/tools/apiresp"
|
||||
"github.com/openimsdk/tools/log"
|
||||
)
|
||||
|
||||
type CaptchaApi struct {
|
||||
Client pbcaptcha.CaptchaClient
|
||||
}
|
||||
|
||||
func NewCaptchaApi(client pbcaptcha.CaptchaClient) *CaptchaApi {
|
||||
return &CaptchaApi{Client: client}
|
||||
}
|
||||
|
||||
func (c *CaptchaApi) GenerateCaptcha(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbcaptcha.GenerateCaptchaReq](ctx)
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha generate request parse failed", err)
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := c.Client.GenerateCaptcha(ctx, req)
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha generate rpc failed", err)
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
|
||||
func (c *CaptchaApi) VerifyCaptcha(ctx *gin.Context) {
|
||||
req, err := a2r.ParseRequestNotCheck[pbcaptcha.VerifyCaptchaReq](ctx)
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha verify request parse failed", err)
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
resp, err := c.Client.VerifyCaptcha(ctx, req)
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha verify rpc failed", err, "captchaID", req.GetCaptchaID(), "x", req.GetX(), "y", req.GetY())
|
||||
apiresp.GinError(ctx, err)
|
||||
return
|
||||
}
|
||||
apiresp.GinSuccess(ctx, resp)
|
||||
}
|
||||
@ -47,6 +47,7 @@ func Start(ctx context.Context, index int, cfg *Config) error {
|
||||
// Determine whether zk is passed according to whether it is a clustered deployment
|
||||
client, err = kdisc.NewDiscoveryRegister(&cfg.Discovery, &cfg.Share, []string{
|
||||
cfg.Share.RpcRegisterName.MessageGateway,
|
||||
cfg.Share.RpcRegisterName.Captcha,
|
||||
})
|
||||
if err != nil {
|
||||
return errs.WrapMsg(err, "failed to register discovery service")
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/rpcli"
|
||||
pbAuth "github.com/openimsdk/protocol/auth"
|
||||
pbcaptcha "github.com/openimsdk/protocol/captcha"
|
||||
"github.com/openimsdk/protocol/conversation"
|
||||
"github.com/openimsdk/protocol/group"
|
||||
"github.com/openimsdk/protocol/msg"
|
||||
@ -98,6 +99,10 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
captchaConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Captcha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
@ -115,6 +120,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
|
||||
r.Use(prommetricsGin(), gin.RecoveryWithWriter(gin.DefaultErrorWriter, mw.GinPanicErr), mw.CorsHandler(), mw.GinParseOperationID(), GinParseToken(rpcli.NewAuthClient(authConn)))
|
||||
u := NewUserApi(user.NewUserClient(userConn), client, config.Share.RpcRegisterName)
|
||||
m := NewMessageApi(msg.NewMsgClient(msgConn), rpcli.NewUserClient(userConn), config.Share.IMAdminUserID)
|
||||
cp := NewCaptchaApi(pbcaptcha.NewCaptchaClient(captchaConn))
|
||||
bl := NewUserGlobalBlackApi(blacklistCtrl, userDB, config.Share.IMAdminUserID, rpcli.NewAuthClient(authConn))
|
||||
userRouterGroup := r.Group("/user")
|
||||
{
|
||||
@ -288,6 +294,12 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
|
||||
conversationGroup.POST("/get_pinned_conversation_ids", c.GetPinnedConversationIDs)
|
||||
}
|
||||
|
||||
{
|
||||
captchaGroup := r.Group("/captcha")
|
||||
captchaGroup.POST("/generate", cp.GenerateCaptcha)
|
||||
captchaGroup.POST("/verify", cp.VerifyCaptcha)
|
||||
}
|
||||
|
||||
{
|
||||
statisticsGroup := r.Group("/statistics")
|
||||
statisticsGroup.POST("/user/register", u.UserRegisterCount)
|
||||
|
||||
167
internal/rpc/captcha/captcha.go
Normal file
@ -0,0 +1,167 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/config"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/servererrs"
|
||||
pbcaptcha "github.com/openimsdk/protocol/captcha"
|
||||
"github.com/openimsdk/tools/db/mongoutil"
|
||||
"github.com/openimsdk/tools/discovery"
|
||||
"github.com/openimsdk/tools/log"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/wenlng/go-captcha/v2/base/option"
|
||||
"github.com/wenlng/go-captcha/v2/slide"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
RpcConfig config.Captcha
|
||||
MongodbConfig config.Mongo
|
||||
Share config.Share
|
||||
Discovery config.Discovery
|
||||
}
|
||||
|
||||
type server struct {
|
||||
pbcaptcha.UnimplementedCaptchaServer
|
||||
conf config.Captcha
|
||||
capt slide.Captcha
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
type captchaDoc struct {
|
||||
CaptchaID string `bson:"captcha_id"`
|
||||
X int `bson:"x"`
|
||||
Y int `bson:"y"`
|
||||
ExpiredAt time.Time `bson:"expired_at"`
|
||||
CreateTime time.Time `bson:"create_time"`
|
||||
VerifyTime time.Time `bson:"verify_time,omitempty"`
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, cfg *Config, _ discovery.SvcDiscoveryRegistry, grpcServer *grpc.Server) error {
|
||||
mongoClient, err := mongoutil.NewMongoDB(ctx, cfg.MongodbConfig.Build())
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha connect mongodb failed", err)
|
||||
return err
|
||||
}
|
||||
collection := mongoClient.GetDB().Collection("captcha")
|
||||
_, err = collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "captcha_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{{Key: "expired_at", Value: 1}},
|
||||
Options: options.Index().SetExpireAfterSeconds(0),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha create mongodb indexes failed", err)
|
||||
return err
|
||||
}
|
||||
|
||||
resources, err := loadResources()
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha load resources failed", err)
|
||||
return err
|
||||
}
|
||||
|
||||
builder := slide.NewBuilder()
|
||||
builder.SetResources(resources...)
|
||||
s := &server{
|
||||
conf: cfg.RpcConfig,
|
||||
capt: builder.Make(),
|
||||
collection: collection,
|
||||
}
|
||||
if s.conf.ExpireSeconds <= 0 {
|
||||
s.conf.ExpireSeconds = 120
|
||||
}
|
||||
if s.conf.VerifyPadding <= 0 {
|
||||
s.conf.VerifyPadding = 8
|
||||
}
|
||||
pbcaptcha.RegisterCaptchaServer(grpcServer, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) GenerateCaptcha(ctx context.Context, _ *pbcaptcha.GenerateCaptchaReq) (*pbcaptcha.GenerateCaptchaResp, error) {
|
||||
captData, err := s.capt.Generate()
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha generate failed", err)
|
||||
return nil, err
|
||||
}
|
||||
block := captData.GetData()
|
||||
masterImage, err := captData.GetMasterImage().ToBase64DataWithQuality(option.QualityNone)
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha encode master image failed", err)
|
||||
return nil, err
|
||||
}
|
||||
tileImage, err := captData.GetTileImage().ToBase64Data()
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha encode tile image failed", err)
|
||||
return nil, err
|
||||
}
|
||||
id := uuid.NewString()
|
||||
now := time.Now()
|
||||
expiredAt := now.Add(time.Duration(s.conf.ExpireSeconds) * time.Second)
|
||||
_, err = s.collection.InsertOne(ctx, captchaDoc{
|
||||
CaptchaID: id,
|
||||
X: block.X,
|
||||
Y: block.Y,
|
||||
ExpiredAt: expiredAt,
|
||||
CreateTime: now,
|
||||
})
|
||||
if err != nil {
|
||||
log.ZError(ctx, "captcha insert mongodb failed", err, "captchaID", id)
|
||||
return nil, err
|
||||
}
|
||||
return &pbcaptcha.GenerateCaptchaResp{
|
||||
CaptchaID: id,
|
||||
MasterImage: masterImage,
|
||||
TileImage: tileImage,
|
||||
TileY: int32(block.DY),
|
||||
ExpireAt: expiredAt.Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) VerifyCaptcha(ctx context.Context, req *pbcaptcha.VerifyCaptchaReq) (*pbcaptcha.VerifyCaptchaResp, error) {
|
||||
now := time.Now()
|
||||
filter := bson.M{
|
||||
"captcha_id": req.CaptchaID,
|
||||
"verify_time": bson.M{"$exists": false},
|
||||
}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"verify_time": now,
|
||||
},
|
||||
}
|
||||
var doc captchaDoc
|
||||
err := s.collection.FindOneAndUpdate(
|
||||
ctx,
|
||||
filter,
|
||||
update,
|
||||
options.FindOneAndUpdate().SetReturnDocument(options.Before),
|
||||
).Decode(&doc)
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
log.ZWarn(ctx, "captcha not found or already verified", err, "captchaID", req.CaptchaID)
|
||||
return nil, servererrs.ErrRecordNotFound.WrapMsg("captcha not found, expired, or already verified", "captchaID", req.CaptchaID)
|
||||
}
|
||||
log.ZError(ctx, "captcha verify query failed", err, "captchaID", req.CaptchaID)
|
||||
return nil, servererrs.ErrDatabase.WrapMsg("verify captcha query failed", "captchaID", req.CaptchaID)
|
||||
}
|
||||
if now.After(doc.ExpiredAt) {
|
||||
log.ZWarn(ctx, "captcha expired", nil, "captchaID", req.CaptchaID, "expiredAt", doc.ExpiredAt.Unix())
|
||||
return nil, servererrs.ErrFileUploadedExpired.WrapMsg("captcha expired", "captchaID", req.CaptchaID)
|
||||
}
|
||||
success := slide.Validate(int(req.X), int(req.Y), doc.X, doc.Y, s.conf.VerifyPadding)
|
||||
if !success {
|
||||
log.ZWarn(ctx, "captcha validate failed", nil, "captchaID", req.CaptchaID, "x", req.X, "y", req.Y)
|
||||
}
|
||||
return &pbcaptcha.VerifyCaptchaResp{Success: success}, nil
|
||||
}
|
||||
13
internal/rpc/captcha/embed.go
Normal file
@ -0,0 +1,13 @@
|
||||
package captcha
|
||||
|
||||
import "embed"
|
||||
|
||||
// resourceFS embeds background images and tile images at compile time.
|
||||
// Background images come from go-captcha-resources (sourcedata/images/image-{1..5}).
|
||||
// Tile images come from go-captcha-resources (sourcedata/tiles/tile-{1..4}):
|
||||
// overlay.png → GraphImage.OverlayImage
|
||||
// shadow.png → GraphImage.ShadowImage
|
||||
// mask.png → GraphImage.MaskImage
|
||||
//
|
||||
//go:embed resources/images/*.jpg resources/tiles/*/*.png
|
||||
var resourceFS embed.FS
|
||||
78
internal/rpc/captcha/resources.go
Normal file
@ -0,0 +1,78 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
"github.com/wenlng/go-captcha/v2/slide"
|
||||
)
|
||||
|
||||
// loadResources reads the embedded files and returns slide.Resource options
|
||||
// ready to be passed to slide.NewBuilder().SetResources(...).
|
||||
func loadResources() ([]slide.Resource, error) {
|
||||
backgrounds, err := loadBackgrounds()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load captcha backgrounds: %w", err)
|
||||
}
|
||||
graphImages, err := loadGraphImages()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load captcha graph images: %w", err)
|
||||
}
|
||||
return []slide.Resource{
|
||||
slide.WithBackgrounds(backgrounds),
|
||||
slide.WithGraphImages(graphImages),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadBackgrounds decodes the embedded JPEG background images.
|
||||
func loadBackgrounds() ([]image.Image, error) {
|
||||
const count = 5
|
||||
images := make([]image.Image, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
path := fmt.Sprintf("resources/images/image-%d.jpg", i)
|
||||
img, err := decodeEmbedImage(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// loadGraphImages decodes the 4 sets of tile overlay/shadow/mask PNG images.
|
||||
func loadGraphImages() ([]*slide.GraphImage, error) {
|
||||
const count = 4
|
||||
graphs := make([]*slide.GraphImage, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
overlay, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/overlay.png", i))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode tile-%d overlay: %w", i, err)
|
||||
}
|
||||
shadow, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/shadow.png", i))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode tile-%d shadow: %w", i, err)
|
||||
}
|
||||
mask, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/mask.png", i))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode tile-%d mask: %w", i, err)
|
||||
}
|
||||
graphs = append(graphs, &slide.GraphImage{
|
||||
OverlayImage: overlay,
|
||||
ShadowImage: shadow,
|
||||
MaskImage: mask,
|
||||
})
|
||||
}
|
||||
return graphs, nil
|
||||
}
|
||||
|
||||
func decodeEmbedImage(path string) (image.Image, error) {
|
||||
f, err := resourceFS.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
img, _, err := image.Decode(f)
|
||||
return img, err
|
||||
}
|
||||
BIN
internal/rpc/captcha/resources/images/image-1.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
internal/rpc/captcha/resources/images/image-2.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
internal/rpc/captcha/resources/images/image-3.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
internal/rpc/captcha/resources/images/image-4.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
internal/rpc/captcha/resources/images/image-5.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-1/mask.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-1/overlay.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-1/shadow.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-2/mask.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-2/overlay.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-2/shadow.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-3/mask.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-3/overlay.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-3/shadow.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-4/mask.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-4/overlay.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
internal/rpc/captcha/resources/tiles/tile-4/shadow.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
26
magefile.go
@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/magefile/mage/sh"
|
||||
"github.com/openimsdk/gomake/mageutil"
|
||||
"github.com/openimsdk/open-im-server/v3/version"
|
||||
"github.com/openimsdk/tools/utils/datautil"
|
||||
@ -37,6 +38,14 @@ func Build() {
|
||||
if len(bin) != 0 {
|
||||
bin = bin[1:]
|
||||
}
|
||||
|
||||
mageutil.WithSpinner("Generating protocol artifacts...", func() {
|
||||
if err := sh.Run("mage", "-d", "protocol", "GenGo"); err != nil {
|
||||
mageutil.PrintRed("protocol compilation failed: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
mageutil.WithSpinner("Building binaries...", func() { mageutil.Build(bin, nil, nil) })
|
||||
}
|
||||
|
||||
@ -54,11 +63,28 @@ func BuildWithCustomConfig() {
|
||||
ToolsDir: &customToolsDir,
|
||||
}
|
||||
|
||||
mageutil.WithSpinner("Generating protocol artifacts...", func() {
|
||||
if err := sh.Run("mage", "-d", "protocol", "GenGo"); err != nil {
|
||||
mageutil.PrintRed("protocol compilation failed: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
mageutil.WithSpinner("Building binaries with custom config...", func() {
|
||||
mageutil.Build(bin, config, nil)
|
||||
})
|
||||
}
|
||||
|
||||
// Protocol generates protobuf artifacts under `./protocol`.
|
||||
func Protocol() {
|
||||
mageutil.WithSpinner("Generating protocol artifacts...", func() {
|
||||
if err := sh.Run("mage", "-d", "protocol", "GenGo"); err != nil {
|
||||
mageutil.PrintRed("protocol compilation failed: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Start() {
|
||||
mageutil.InitForSSC()
|
||||
err := setMaxOpenFiles()
|
||||
|
||||
@ -34,7 +34,9 @@ var (
|
||||
OpenIMMsgGatewayCfgFileName string
|
||||
OpenIMMsgTransferCfgFileName string
|
||||
OpenIMPushCfgFileName string
|
||||
OpenIMCaptchaCfgFileName string
|
||||
OpenIMRPCAuthCfgFileName string
|
||||
OpenIMRPCCaptchaCfgFileName string
|
||||
OpenIMRPCConversationCfgFileName string
|
||||
OpenIMRPCFriendCfgFileName string
|
||||
OpenIMRPCGroupCfgFileName string
|
||||
@ -62,7 +64,9 @@ func init() {
|
||||
OpenIMMsgGatewayCfgFileName = "openim-msggateway.yml"
|
||||
OpenIMMsgTransferCfgFileName = "openim-msgtransfer.yml"
|
||||
OpenIMPushCfgFileName = "openim-push.yml"
|
||||
OpenIMCaptchaCfgFileName = "openim-captcha.yml"
|
||||
OpenIMRPCAuthCfgFileName = "openim-rpc-auth.yml"
|
||||
OpenIMRPCCaptchaCfgFileName = "openim-rpc-captcha.yml"
|
||||
OpenIMRPCConversationCfgFileName = "openim-rpc-conversation.yml"
|
||||
OpenIMRPCFriendCfgFileName = "openim-rpc-friend.yml"
|
||||
OpenIMRPCGroupCfgFileName = "openim-rpc-group.yml"
|
||||
@ -77,7 +81,7 @@ func init() {
|
||||
KafkaConfigFileName, RedisConfigFileName,
|
||||
MongodbConfigFileName, MinioConfigFileName, LogConfigFileName,
|
||||
OpenIMAPICfgFileName, OpenIMCronTaskCfgFileName, OpenIMMsgGatewayCfgFileName,
|
||||
OpenIMMsgTransferCfgFileName, OpenIMPushCfgFileName, OpenIMRPCAuthCfgFileName,
|
||||
OpenIMMsgTransferCfgFileName, OpenIMPushCfgFileName, OpenIMCaptchaCfgFileName, OpenIMRPCAuthCfgFileName, OpenIMRPCCaptchaCfgFileName,
|
||||
OpenIMRPCConversationCfgFileName, OpenIMRPCFriendCfgFileName, OpenIMRPCGroupCfgFileName,
|
||||
OpenIMRPCMsgCfgFileName, OpenIMRPCThirdCfgFileName, OpenIMRPCUserCfgFileName, DiscoveryConfigFilename,
|
||||
}
|
||||
|
||||
47
pkg/common/cmd/rpc_captcha.go
Normal file
@ -0,0 +1,47 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/openimsdk/open-im-server/v3/internal/rpc/captcha"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/startrpc"
|
||||
"github.com/openimsdk/open-im-server/v3/version"
|
||||
"github.com/openimsdk/tools/system/program"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type CaptchaRpcCmd struct {
|
||||
*RootCmd
|
||||
ctx context.Context
|
||||
configMap map[string]any
|
||||
captchaConfig *captcha.Config
|
||||
}
|
||||
|
||||
func NewCaptchaRpcCmd() *CaptchaRpcCmd {
|
||||
var captchaConfig captcha.Config
|
||||
ret := &CaptchaRpcCmd{captchaConfig: &captchaConfig}
|
||||
ret.configMap = map[string]any{
|
||||
OpenIMRPCCaptchaCfgFileName: &captchaConfig.RpcConfig,
|
||||
MongodbConfigFileName: &captchaConfig.MongodbConfig,
|
||||
ShareFileName: &captchaConfig.Share,
|
||||
DiscoveryConfigFilename: &captchaConfig.Discovery,
|
||||
}
|
||||
ret.RootCmd = NewRootCmd(program.GetProcessName(), WithConfigMap(ret.configMap))
|
||||
ret.ctx = context.WithValue(context.Background(), "version", version.Version)
|
||||
ret.Command.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
return ret.runE()
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *CaptchaRpcCmd) Exec() error {
|
||||
return c.Execute()
|
||||
}
|
||||
|
||||
func (c *CaptchaRpcCmd) runE() error {
|
||||
return startrpc.Start(c.ctx, &c.captchaConfig.Discovery, &c.captchaConfig.RpcConfig.Prometheus, c.captchaConfig.RpcConfig.RPC.ListenIP,
|
||||
c.captchaConfig.RpcConfig.RPC.RegisterIP, c.captchaConfig.RpcConfig.RPC.AutoSetPorts, c.captchaConfig.RpcConfig.RPC.Ports,
|
||||
c.Index(), c.captchaConfig.Share.RpcRegisterName.Captcha, &c.captchaConfig.Share, c.captchaConfig,
|
||||
nil,
|
||||
captcha.Start)
|
||||
}
|
||||
@ -249,6 +249,18 @@ type Auth struct {
|
||||
} `mapstructure:"tokenPolicy"`
|
||||
}
|
||||
|
||||
type Captcha struct {
|
||||
RPC struct {
|
||||
RegisterIP string `mapstructure:"registerIP"`
|
||||
ListenIP string `mapstructure:"listenIP"`
|
||||
AutoSetPorts bool `mapstructure:"autoSetPorts"`
|
||||
Ports []int `mapstructure:"ports"`
|
||||
} `mapstructure:"rpc"`
|
||||
Prometheus Prometheus `mapstructure:"prometheus"`
|
||||
VerifyPadding int `mapstructure:"verifyPadding"`
|
||||
ExpireSeconds int `mapstructure:"expireSeconds"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
RPC struct {
|
||||
RegisterIP string `mapstructure:"registerIP"`
|
||||
@ -408,6 +420,7 @@ type RpcRegisterName struct {
|
||||
Auth string `mapstructure:"auth"`
|
||||
Conversation string `mapstructure:"conversation"`
|
||||
Third string `mapstructure:"third"`
|
||||
Captcha string `mapstructure:"captcha"`
|
||||
}
|
||||
|
||||
func (r *RpcRegisterName) GetServiceNames() []string {
|
||||
@ -421,6 +434,7 @@ func (r *RpcRegisterName) GetServiceNames() []string {
|
||||
r.Auth,
|
||||
r.Conversation,
|
||||
r.Third,
|
||||
r.Captcha,
|
||||
}
|
||||
}
|
||||
|
||||
@ -626,6 +640,7 @@ var (
|
||||
OpenIMMsgTransferCfgFileName = "openim-msgtransfer.yml"
|
||||
OpenIMPushCfgFileName = "openim-push.yml"
|
||||
OpenIMRPCAuthCfgFileName = "openim-rpc-auth.yml"
|
||||
OpenIMRPCCaptchaCfgFileName = "openim-rpc-captcha.yml"
|
||||
OpenIMRPCConversationCfgFileName = "openim-rpc-conversation.yml"
|
||||
OpenIMRPCFriendCfgFileName = "openim-rpc-friend.yml"
|
||||
OpenIMRPCGroupCfgFileName = "openim-rpc-group.yml"
|
||||
@ -689,6 +704,10 @@ func (a *Auth) GetConfigFileName() string {
|
||||
return OpenIMRPCAuthCfgFileName
|
||||
}
|
||||
|
||||
func (c *Captcha) GetConfigFileName() string {
|
||||
return OpenIMRPCCaptchaCfgFileName
|
||||
}
|
||||
|
||||
func (c *Conversation) GetConfigFileName() string {
|
||||
return OpenIMRPCConversationCfgFileName
|
||||
}
|
||||
|
||||
318
scripts/test/captcha_api_test.sh
Executable file
@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# Captcha API 接口测试脚本
|
||||
#
|
||||
# 覆盖接口:
|
||||
# POST /captcha/generate —— 生成滑块验证码
|
||||
# POST /captcha/verify —— 验证滑块验证码
|
||||
#
|
||||
# 依赖:curl / jq
|
||||
# 用法:
|
||||
# chmod +x captcha_api_test.sh
|
||||
# ./captcha_api_test.sh
|
||||
# ./captcha_api_test.sh --host http://127.0.0.1:10002
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 可配置参数(可通过环境变量覆盖)
|
||||
# ──────────────────────────────────────────────
|
||||
HOST="${HOST:-http://127.0.0.1:10002}"
|
||||
ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}"
|
||||
ADMIN_SECRET="${ADMIN_SECRET:-openIM123}"
|
||||
PLATFORM_ID="${PLATFORM_ID:-1}" # 1=iOS 2=Android 3=Windows ...
|
||||
|
||||
# 命令行参数解析
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--host) HOST="$2"; shift 2 ;;
|
||||
--admin-user-id) ADMIN_USER_ID="$2"; shift 2 ;;
|
||||
--admin-secret) ADMIN_SECRET="$2"; shift 2 ;;
|
||||
*) echo "未知参数: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 颜色输出
|
||||
# ──────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
|
||||
PASS=0; FAIL=0
|
||||
|
||||
pass() { echo -e "${GREEN} [PASS]${NC} $1"; PASS=$((PASS+1)); }
|
||||
fail() { echo -e "${RED} [FAIL]${NC} $1"; FAIL=$((FAIL+1)); }
|
||||
info() { echo -e "${CYAN} [INFO]${NC} $1"; }
|
||||
section() { echo -e "\n${YELLOW}══ $1 ══${NC}"; }
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 生成唯一 operationID(每次调用递增)
|
||||
# ──────────────────────────────────────────────
|
||||
_OP_SEQ=0
|
||||
new_op_id() {
|
||||
(( _OP_SEQ++ ))
|
||||
echo "captcha-test-$$-${_OP_SEQ}"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 断言工具函数
|
||||
# ──────────────────────────────────────────────
|
||||
assert_err_code() {
|
||||
local resp="$1" expected="$2" desc="$3"
|
||||
local actual
|
||||
actual=$(echo "$resp" | jq -r '.errCode // "null"')
|
||||
if [[ "${actual}" == "${expected}" ]]; then
|
||||
pass "${desc} (errCode=${actual})"
|
||||
else
|
||||
fail "${desc} - expected errCode=${expected}, got errCode=${actual}"
|
||||
info "resp: ${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_empty() {
|
||||
local resp="$1" jq_path="$2" desc="$3"
|
||||
local val
|
||||
val=$(echo "$resp" | jq -r "$jq_path // empty")
|
||||
if [[ -n "${val}" && "${val}" != "null" ]]; then
|
||||
pass "${desc} (val=${val:0:40}...)"
|
||||
else
|
||||
fail "${desc} - '${jq_path}' is empty or null"
|
||||
info "resp: ${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local resp="$1" jq_path="$2" expected="$3" desc="$4"
|
||||
local actual
|
||||
# 不使用 // empty:jq 的 // 运算符会把布尔 false 视为 false 并走替代分支
|
||||
actual=$(echo "$resp" | jq -r "$jq_path")
|
||||
if [[ "${actual}" == "${expected}" ]]; then
|
||||
pass "${desc} (val=${actual})"
|
||||
else
|
||||
fail "${desc} - expected=${expected}, got=${actual}"
|
||||
info "resp: ${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
# errCode 非 0 即通过
|
||||
assert_err_nonzero() {
|
||||
local resp="$1" desc="$2"
|
||||
local actual
|
||||
actual=$(echo "$resp" | jq -r '.errCode // "null"')
|
||||
if [[ "${actual}" != "0" && "${actual}" != "null" ]]; then
|
||||
pass "${desc} (errCode=${actual})"
|
||||
else
|
||||
fail "${desc} - expected errCode!=0, got errCode=${actual}"
|
||||
info "resp: ${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 前置:获取 Admin Token
|
||||
# ──────────────────────────────────────────────
|
||||
section "前置:获取 Admin Token"
|
||||
|
||||
TOKEN_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d "{\"secret\":\"${ADMIN_SECRET}\",\"platformID\":${PLATFORM_ID},\"userID\":\"${ADMIN_USER_ID}\"}" \
|
||||
"${HOST}/auth/get_admin_token")
|
||||
|
||||
info "Token 响应: $TOKEN_RESP"
|
||||
|
||||
ERR_CODE=$(echo "$TOKEN_RESP" | jq -r '.errCode // "null"')
|
||||
if [[ "$ERR_CODE" != "0" ]]; then
|
||||
echo -e "${RED}[ERROR]${NC} 获取 Admin Token 失败 (errCode=$ERR_CODE),中止测试"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(echo "$TOKEN_RESP" | jq -r '.data.token')
|
||||
info "获取到 token: ${TOKEN:0:40}..."
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 用例 1:生成验证码 —— 正常流程
|
||||
# ──────────────────────────────────────────────
|
||||
section "用例 1 / POST /captcha/generate —— 正常生成验证码"
|
||||
|
||||
GEN_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: ${TOKEN}" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d '{}' \
|
||||
"${HOST}/captcha/generate")
|
||||
|
||||
info "响应摘要: $(echo "${GEN_RESP}" | jq -c '{errCode,errMsg,data:{captchaID:.data.captchaID,expireAt:.data.expireAt}}')"
|
||||
|
||||
GEN_ERR=$(echo "${GEN_RESP}" | jq -r '.errCode // "null"')
|
||||
GEN_MSG=$(echo "${GEN_RESP}" | jq -r '.errMsg // ""')
|
||||
|
||||
# 检测服务端是否因缺少背景图资源而报 500
|
||||
if [[ "${GEN_ERR}" == "500" && "${GEN_MSG}" == *"background"* ]]; then
|
||||
fail "用例 1 跳过 - captcha 服务未配置背景图资源 (errMsg=${GEN_MSG})"
|
||||
info "修复方式:在 captcha.Start() 中通过 slide.NewBuilder().SetBackground(...).Make() 注入背景图"
|
||||
CAPTCHA_ID=""
|
||||
EXPIRE_AT=""
|
||||
else
|
||||
assert_err_code "${GEN_RESP}" "0" "生成验证码 errCode 应为 0"
|
||||
assert_not_empty "${GEN_RESP}" ".data.captchaID" "captchaID 非空"
|
||||
assert_not_empty "${GEN_RESP}" ".data.masterImage" "masterImage(背景图 Base64) 非空"
|
||||
assert_not_empty "${GEN_RESP}" ".data.tileImage" "tileImage(滑块图 Base64) 非空"
|
||||
assert_not_empty "${GEN_RESP}" ".data.expireAt" "expireAt(过期 Unix 时间戳) 非空"
|
||||
CAPTCHA_ID=$(echo "${GEN_RESP}" | jq -r '.data.captchaID')
|
||||
EXPIRE_AT=$(echo "${GEN_RESP}" | jq -r '.data.expireAt')
|
||||
info "captchaID = ${CAPTCHA_ID}"
|
||||
info "expireAt = ${EXPIRE_AT}"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 用例 2:生成验证码 —— 不携带 Token
|
||||
# ──────────────────────────────────────────────
|
||||
section "用例 2 / POST /captcha/generate —— 无 Token 应被鉴权中间件拦截"
|
||||
|
||||
NO_TOKEN_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d '{}' \
|
||||
"${HOST}/captcha/generate")
|
||||
|
||||
info "响应: $NO_TOKEN_RESP"
|
||||
assert_err_nonzero "$NO_TOKEN_RESP" "无 Token 被鉴权中间件拦截"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 用例 3:验证验证码 —— 坐标错误(x=999, y=999)
|
||||
# ──────────────────────────────────────────────
|
||||
section "用例 3 / POST /captcha/verify —— 坐标错误,success 应为 false"
|
||||
|
||||
if [[ -z "${CAPTCHA_ID}" ]]; then
|
||||
fail "用例 3 跳过 - 依赖用例 1 生成的 captchaID,但用例 1 未成功"
|
||||
else
|
||||
VERIFY_WRONG_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: ${TOKEN}" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d "{\"captchaID\":\"${CAPTCHA_ID}\",\"x\":999,\"y\":999}" \
|
||||
"${HOST}/captcha/verify")
|
||||
info "响应: ${VERIFY_WRONG_RESP}"
|
||||
assert_err_code "${VERIFY_WRONG_RESP}" "0" "验证请求本身成功 errCode=0"
|
||||
assert_eq "${VERIFY_WRONG_RESP}" ".data.success" "false" "坐标错误时 success=false"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 用例 4:验证验证码 —— 重复使用同一 captchaID
|
||||
# 用例 3 已消耗该 ID(verify_time 已被 FindOneAndUpdate 写入),
|
||||
# 再次调用服务端 filter 匹配不到记录,应返回错误
|
||||
# ──────────────────────────────────────────────
|
||||
section "用例 4 / POST /captcha/verify —— 重复使用同一 captchaID(幂等),应返回错误"
|
||||
|
||||
if [[ -z "${CAPTCHA_ID}" ]]; then
|
||||
fail "用例 4 跳过 - 依赖用例 1 生成的 captchaID,但用例 1 未成功"
|
||||
else
|
||||
VERIFY_REUSE_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: ${TOKEN}" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d "{\"captchaID\":\"${CAPTCHA_ID}\",\"x\":0,\"y\":0}" \
|
||||
"${HOST}/captcha/verify")
|
||||
info "响应: ${VERIFY_REUSE_RESP}"
|
||||
assert_err_nonzero "${VERIFY_REUSE_RESP}" "重复使用 captchaID 被拒绝"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 用例 5:验证验证码 —— captchaID 不存在
|
||||
# ──────────────────────────────────────────────
|
||||
section "用例 5 / POST /captcha/verify —— captchaID 不存在,应返回错误"
|
||||
|
||||
VERIFY_NOTFOUND_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: ${TOKEN}" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d '{"captchaID":"00000000-0000-0000-0000-000000000000","x":10,"y":10}' \
|
||||
"${HOST}/captcha/verify")
|
||||
|
||||
info "响应: $VERIFY_NOTFOUND_RESP"
|
||||
assert_err_nonzero "$VERIFY_NOTFOUND_RESP" "captchaID 不存在时返回错误"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 用例 6:验证验证码 —— captchaID 为空字符串
|
||||
# ──────────────────────────────────────────────
|
||||
section "用例 6 / POST /captcha/verify —— captchaID 为空字符串,应返回错误"
|
||||
|
||||
VERIFY_EMPTY_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: ${TOKEN}" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d '{"captchaID":"","x":10,"y":10}' \
|
||||
"${HOST}/captcha/verify")
|
||||
|
||||
info "响应: $VERIFY_EMPTY_RESP"
|
||||
assert_err_nonzero "$VERIFY_EMPTY_RESP" "captchaID 为空时返回错误"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 用例 7:验证验证码 —— 不携带 Token
|
||||
# ──────────────────────────────────────────────
|
||||
section "用例 7 / POST /captcha/verify —— 无 Token 应被鉴权中间件拦截"
|
||||
|
||||
VERIFY_NOTOKEN_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d "{\"captchaID\":\"${CAPTCHA_ID:-00000000-0000-0000-0000-000000000000}\",\"x\":10,\"y\":10}" \
|
||||
"${HOST}/captcha/verify")
|
||||
|
||||
info "响应: $VERIFY_NOTOKEN_RESP"
|
||||
assert_err_nonzero "$VERIFY_NOTOKEN_RESP" "无 Token 被鉴权中间件拦截"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 用例 8:完整正向链路 —— 新生成 + 用偏差坐标验证
|
||||
# 服务端不回传正确坐标,用 (0,0) 验证 success=false
|
||||
# 正确坐标可从 MongoDB 查询:
|
||||
# db.captcha.findOne({captcha_id: "<ID>"}, {x:1,y:1})
|
||||
# ──────────────────────────────────────────────
|
||||
section "用例 8 / 完整正向链路 —— 新生成验证码 → 坐标偏差验证"
|
||||
|
||||
GEN_RESP2=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: ${TOKEN}" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d '{}' \
|
||||
"${HOST}/captcha/generate")
|
||||
|
||||
GEN_ERR2=$(echo "${GEN_RESP2}" | jq -r '.errCode // "null"')
|
||||
GEN_MSG2=$(echo "${GEN_RESP2}" | jq -r '.errMsg // ""')
|
||||
|
||||
if [[ "${GEN_ERR2}" == "500" && "${GEN_MSG2}" == *"background"* ]]; then
|
||||
fail "用例 8 跳过 - captcha 服务未配置背景图资源 (errMsg=${GEN_MSG2})"
|
||||
else
|
||||
CAPTCHA_ID2=$(echo "${GEN_RESP2}" | jq -r '.data.captchaID')
|
||||
EXPIRE_AT2=$(echo "${GEN_RESP2}" | jq -r '.data.expireAt')
|
||||
MASTER_LEN=$(echo "${GEN_RESP2}" | jq -r '.data.masterImage | length')
|
||||
TILE_LEN=$(echo "${GEN_RESP2}" | jq -r '.data.tileImage | length')
|
||||
|
||||
assert_err_code "${GEN_RESP2}" "0" "新一轮生成验证码成功"
|
||||
assert_not_empty "${GEN_RESP2}" ".data.captchaID" "captchaID2 非空"
|
||||
|
||||
info "captchaID2 = ${CAPTCHA_ID2}"
|
||||
info "expireAt = ${EXPIRE_AT2}"
|
||||
info "masterImage 长度 = ${MASTER_LEN} chars(Base64)"
|
||||
info "tileImage 长度 = ${TILE_LEN} chars(Base64)"
|
||||
info "查询真实坐标: db.captcha.findOne({captcha_id:\"${CAPTCHA_ID2}\"},{x:1,y:1})"
|
||||
|
||||
VERIFY_LINK_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "token: ${TOKEN}" \
|
||||
-H "operationID: $(new_op_id)" \
|
||||
-d "{\"captchaID\":\"${CAPTCHA_ID2}\",\"x\":0,\"y\":0}" \
|
||||
"${HOST}/captcha/verify")
|
||||
|
||||
assert_err_code "${VERIFY_LINK_RESP}" "0" "验证接口响应正常 errCode=0"
|
||||
assert_eq "${VERIFY_LINK_RESP}" ".data.success" "false" "偏差坐标(0,0) success=false"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 汇总
|
||||
# ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "══════════════════════════════════════════"
|
||||
echo -e " 测试汇总:${GREEN}PASS=${PASS}${NC} ${RED}FAIL=${FAIL}${NC}"
|
||||
echo -e "══════════════════════════════════════════"
|
||||
|
||||
[[ $FAIL -eq 0 ]]
|
||||
316
scripts/test/captcha_demo.html
Normal file
@ -0,0 +1,316 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Captcha 验证码测试</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
background:#f0f2f5;padding:20px;min-height:100vh}
|
||||
|
||||
/* ── 卡片 ── */
|
||||
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);
|
||||
padding:20px;max-width:500px;margin:0 auto 16px}
|
||||
.card-title{font-size:15px;font-weight:600;color:#1a1a2e;margin-bottom:14px;
|
||||
display:flex;align-items:center;gap:6px}
|
||||
|
||||
/* ── 表单 ── */
|
||||
.field{margin-bottom:10px}
|
||||
.field label{display:block;font-size:11px;color:#888;margin-bottom:3px}
|
||||
input[type=text]{width:100%;padding:8px 10px;border:1px solid #e0e0e0;border-radius:8px;
|
||||
font-size:12px;outline:none;transition:border .15s}
|
||||
input[type=text]:focus{border-color:#4f46e5}
|
||||
|
||||
/* ── 按钮 ── */
|
||||
.btn{display:inline-flex;align-items:center;gap:5px;padding:8px 16px;border:none;
|
||||
border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;transition:.15s}
|
||||
.btn:hover{filter:brightness(.92)}
|
||||
.btn:disabled{opacity:.45;cursor:not-allowed}
|
||||
.btn-indigo{background:#4f46e5;color:#fff}
|
||||
.btn-gray {background:#6b7280;color:#fff}
|
||||
.btn-green {background:#10b981;color:#fff}
|
||||
.btn-row {display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
||||
|
||||
/* ── 状态标签 ── */
|
||||
.tag{display:inline-block;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600}
|
||||
.tag-ok {background:#d1fae5;color:#065f46}
|
||||
.tag-err {background:#fee2e2;color:#991b1b}
|
||||
.tag-info{background:#e0e7ff;color:#3730a3}
|
||||
#status,#verify-status{margin-top:10px;min-height:24px}
|
||||
|
||||
/* ── 验证码区域 ── */
|
||||
#captcha-section{display:none}
|
||||
|
||||
/* 背景图 + 滑块叠层 */
|
||||
.slide-wrap{
|
||||
position:relative;width:300px;height:220px;
|
||||
border:2px solid #e5e7eb;border-radius:10px;
|
||||
overflow:hidden;background:#f3f4f6;
|
||||
cursor:default;user-select:none;
|
||||
}
|
||||
#master-img{position:absolute;top:0;left:0;width:300px;height:220px;display:block}
|
||||
#tile-img {position:absolute;cursor:grab;transition:none}
|
||||
#tile-img:active{cursor:grabbing}
|
||||
|
||||
/* 滑轨 */
|
||||
.slider-row{display:flex;align-items:center;gap:6px;margin-top:8px}
|
||||
.slider-row span{font-size:11px;color:#6b7280;min-width:14px}
|
||||
.slider-row input[type=range]{flex:1;accent-color:#4f46e5;height:4px}
|
||||
.slider-row .val{font-size:11px;color:#4f46e5;min-width:30px;text-align:right}
|
||||
|
||||
/* 提示箭头 */
|
||||
.hint{font-size:11px;color:#9ca3af;margin-top:6px}
|
||||
|
||||
/* ── 调试区 ── */
|
||||
#debug-area{display:none;margin-top:14px}
|
||||
.debug-label{font-size:10px;color:#9ca3af;margin-bottom:4px}
|
||||
.image-preview{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
|
||||
.image-preview figure{text-align:center}
|
||||
.image-preview figcaption{font-size:10px;color:#888;margin-top:4px}
|
||||
pre{background:#1e1e2e;color:#cdd6f4;border-radius:8px;padding:12px;
|
||||
font-size:10px;overflow:auto;max-height:160px;word-break:break-all}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ① 配置 -->
|
||||
<div class="card">
|
||||
<div class="card-title">⚙️ 配置</div>
|
||||
<div class="field">
|
||||
<label>API Host</label>
|
||||
<input type="text" id="host" value="http://127.0.0.1:10002">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Token(留空则自动获取 Admin Token)</label>
|
||||
<input type="text" id="token" placeholder="eyJhbGci...">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Admin Secret</label>
|
||||
<input type="text" id="secret" value="openIM123">
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-gray" onclick="getToken()">🔑 获取 Token</button>
|
||||
<button class="btn btn-indigo" id="btn-gen" onclick="generate()">🖼 生成验证码</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<!-- ② 验证码交互 -->
|
||||
<div class="card" id="captcha-section">
|
||||
<div class="card-title">🧩 滑块验证码</div>
|
||||
<p class="hint">拖动滑块或直接拖拽图中的小方块,使其嵌入背景缺口后点击「验证」。</p>
|
||||
|
||||
<div style="margin-top:12px">
|
||||
<!-- 背景图 + 浮动滑块 -->
|
||||
<div class="slide-wrap" id="slide-wrap">
|
||||
<img id="master-img" alt="">
|
||||
<img id="tile-img" alt="">
|
||||
</div>
|
||||
|
||||
<!-- X 轴滑轨 -->
|
||||
<div class="slider-row">
|
||||
<span>←→</span>
|
||||
<input type="range" id="sx" min="0" max="240" value="0" oninput="syncTile()">
|
||||
<span class="val" id="sx-val">0 px</span>
|
||||
</div>
|
||||
<!-- Y 轴调试用 -->
|
||||
<div class="slider-row">
|
||||
<span>↕</span>
|
||||
<input type="range" id="sy" min="0" max="160" value="0" oninput="syncTile()">
|
||||
<span class="val" id="sy-val">0 px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-green" onclick="verify()">✅ 验证</button>
|
||||
<button class="btn btn-indigo" onclick="generate()">🔄 刷新</button>
|
||||
</div>
|
||||
<div id="verify-status"></div>
|
||||
|
||||
<!-- 调试:直接渲染两张图 + 原始数据 -->
|
||||
<div id="debug-area">
|
||||
<hr style="border:none;border-top:1px solid #f0f0f0;margin:14px 0">
|
||||
<div class="debug-label">图片预览(直接从 Base64 渲染)</div>
|
||||
<div class="image-preview">
|
||||
<figure>
|
||||
<img id="dbg-master" style="border-radius:6px;border:1px solid #e5e7eb" alt="">
|
||||
<figcaption>masterImage(JPEG 背景)</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img id="dbg-tile" style="border-radius:4px;border:1px solid #e5e7eb;background:#ddd" alt="">
|
||||
<figcaption>tileImage(PNG 滑块)</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="debug-label" style="margin-top:10px">原始响应(去除图片数据)</div>
|
||||
<pre id="raw-json"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── 工具 ── */
|
||||
const $ = id => document.getElementById(id);
|
||||
let captchaState = { id:'', tileW:68, tileH:68, tileY:0 };
|
||||
let opSeq = 0;
|
||||
|
||||
function setStatus(elId, html, type='info'){
|
||||
$(elId).innerHTML = `<span class="tag tag-${type}">${html}</span>`;
|
||||
}
|
||||
|
||||
async function post(path, body){
|
||||
const host = $('host').value.replace(/\/$/,'');
|
||||
const tok = $('token').value.trim();
|
||||
const headers = {
|
||||
'Content-Type':'application/json',
|
||||
'operationID':`demo-${Date.now()}-${++opSeq}`,
|
||||
};
|
||||
if(tok) headers['token'] = tok;
|
||||
const r = await fetch(host+path, {method:'POST', headers, body:JSON.stringify(body)});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/* ── 1. 获取 Token ── */
|
||||
async function getToken(){
|
||||
setStatus('status','获取 Token 中…','info');
|
||||
try{
|
||||
const d = await post('/auth/get_admin_token',{
|
||||
secret:$('secret').value, platformID:1, userID:'imAdmin'
|
||||
});
|
||||
if(d.errCode!==0){ setStatus('status',`获取失败: ${d.errMsg}`,'err'); return; }
|
||||
$('token').value = d.data.token;
|
||||
setStatus('status','Token 获取成功 ✓','ok');
|
||||
}catch(e){ setStatus('status',`请求异常: ${e.message}`,'err'); }
|
||||
}
|
||||
|
||||
/* ── 2. 生成验证码 ── */
|
||||
async function generate(){
|
||||
if(!$('token').value.trim()){ await getToken(); if(!$('token').value.trim()) return; }
|
||||
|
||||
setStatus('status','生成验证码中…','info');
|
||||
$('btn-gen').disabled = true;
|
||||
try{
|
||||
const d = await post('/captcha/generate',{});
|
||||
|
||||
/* 展示原始响应(隐去图片数据保持可读) */
|
||||
const preview = JSON.parse(JSON.stringify(d));
|
||||
if(preview.data){
|
||||
if(preview.data.masterImage) preview.data.masterImage = `<base64 JPEG, ${preview.data.masterImage.length} chars>`;
|
||||
if(preview.data.tileImage) preview.data.tileImage = `<base64 PNG, ${preview.data.tileImage.length} chars>`;
|
||||
}
|
||||
$('raw-json').textContent = JSON.stringify(preview, null, 2);
|
||||
|
||||
if(d.errCode!==0){
|
||||
setStatus('status',`生成失败 errCode=${d.errCode}: ${d.errMsg} — ${d.errDlt}`,'err');
|
||||
return;
|
||||
}
|
||||
|
||||
const {captchaID, masterImage, tileImage, tileY, expireAt} = d.data;
|
||||
captchaState.id = captchaID;
|
||||
captchaState.tileY = tileY || 0;
|
||||
|
||||
/* ── 渲染 masterImage(JPEG)── */
|
||||
const masterSrc = `data:image/jpeg;base64,${masterImage}`;
|
||||
$('master-img').src = masterSrc;
|
||||
$('dbg-master').src = masterSrc;
|
||||
$('dbg-master').style.width = '300px';
|
||||
|
||||
/* ── 渲染 tileImage(PNG)── */
|
||||
const tileSrc = `data:image/png;base64,${tileImage}`;
|
||||
$('dbg-tile').src = tileSrc;
|
||||
|
||||
/* 等 tile 图片加载完毕后设置滑块 */
|
||||
await loadImage($('tile-img'), tileSrc);
|
||||
|
||||
captchaState.tileW = $('tile-img').naturalWidth || 68;
|
||||
captchaState.tileH = $('tile-img').naturalHeight || 68;
|
||||
|
||||
/* 更新滑轨范围,初始化 Y = tileY */
|
||||
$('sx').max = 300 - captchaState.tileW;
|
||||
$('sy').max = 220 - captchaState.tileH;
|
||||
$('sx').value = 0;
|
||||
$('sy').value = captchaState.tileY;
|
||||
syncTile();
|
||||
|
||||
const expire = new Date(expireAt * 1000).toLocaleTimeString();
|
||||
setStatus('status',
|
||||
`验证码已生成 | id: ${captchaID.slice(0,8)}… | tileY: ${tileY} | 过期: ${expire}`,'ok');
|
||||
|
||||
$('captcha-section').style.display = 'block';
|
||||
$('debug-area').style.display = 'block';
|
||||
$('verify-status').innerHTML = '';
|
||||
|
||||
}catch(e){
|
||||
setStatus('status',`请求异常: ${e.message}`,'err');
|
||||
}finally{
|
||||
$('btn-gen').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 等待 img.onload(Promise 封装) */
|
||||
function loadImage(imgEl, src){
|
||||
return new Promise((resolve, reject)=>{
|
||||
imgEl.onload = resolve;
|
||||
imgEl.onerror = reject;
|
||||
imgEl.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 3. 同步滑块位置 ── */
|
||||
function syncTile(){
|
||||
const x = parseInt($('sx').value);
|
||||
const y = parseInt($('sy').value);
|
||||
$('sx-val').textContent = x + ' px';
|
||||
$('sy-val').textContent = y + ' px';
|
||||
$('tile-img').style.left = x + 'px';
|
||||
$('tile-img').style.top = y + 'px';
|
||||
}
|
||||
|
||||
/* ── 4. 鼠标 / 触屏拖拽 ── */
|
||||
(()=>{
|
||||
let drag=false, sx0=0, sliderX0=0;
|
||||
const wrap = $('slide-wrap');
|
||||
const start = (cx)=>{ drag=true; sx0=cx; sliderX0=parseInt($('sx').value); };
|
||||
const move = (cx)=>{
|
||||
if(!drag) return;
|
||||
const nx = Math.max(0, Math.min(parseInt($('sx').max), sliderX0+(cx-sx0)));
|
||||
$('sx').value = nx;
|
||||
syncTile();
|
||||
};
|
||||
const end = ()=>{ drag=false; };
|
||||
|
||||
wrap.addEventListener('mousedown', e=>{ if(e.target.id==='tile-img') start(e.clientX); });
|
||||
document.addEventListener('mousemove', e=>move(e.clientX));
|
||||
document.addEventListener('mouseup', end);
|
||||
|
||||
wrap.addEventListener('touchstart', e=>{
|
||||
if(e.target.id==='tile-img'){ start(e.touches[0].clientX); e.preventDefault(); }
|
||||
},{passive:false});
|
||||
document.addEventListener('touchmove', e=>{
|
||||
if(drag){ move(e.touches[0].clientX); e.preventDefault(); }
|
||||
},{passive:false});
|
||||
document.addEventListener('touchend', end);
|
||||
})();
|
||||
|
||||
/* ── 5. 验证 ── */
|
||||
async function verify(){
|
||||
if(!captchaState.id){ alert('请先生成验证码'); return; }
|
||||
const x = parseInt($('sx').value);
|
||||
const y = parseInt($('sy').value);
|
||||
setStatus('verify-status',`提交验证 x=${x} y=${y}…`,'info');
|
||||
try{
|
||||
const d = await post('/captcha/verify',{captchaID:captchaState.id, x, y});
|
||||
if(d.errCode!==0){
|
||||
setStatus('verify-status',`请求失败 errCode=${d.errCode}: ${d.errMsg}`,'err');
|
||||
return;
|
||||
}
|
||||
if(d.data.success){
|
||||
setStatus('verify-status',`🎉 验证通过!x=${x} y=${y}`,'ok');
|
||||
}else{
|
||||
setStatus('verify-status',`❌ 验证失败,偏差过大,请重新拖动后再试`,'err');
|
||||
}
|
||||
}catch(e){
|
||||
setStatus('verify-status',`请求异常: ${e.message}`,'err');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -7,6 +7,7 @@ serviceBinaries:
|
||||
openim-msgtransfer: 8
|
||||
openim-rpc-conversation: 1
|
||||
openim-rpc-auth: 1
|
||||
openim-rpc-captcha: 1
|
||||
openim-rpc-group: 1
|
||||
openim-rpc-friend: 1
|
||||
openim-rpc-msg: 1
|
||||
|
||||