diff --git a/api/api_bind.go b/api/api_bind.go index 16459032..5fc58c42 100644 --- a/api/api_bind.go +++ b/api/api_bind.go @@ -183,10 +183,7 @@ func forceStop(c echo.Context) error { for _, i := range diceManager.Dice { d := i - err := d.DBOperator.Close() - if err != nil { - return - } + d.DBOperator.Close() } // 清理gocqhttp diff --git a/dice/config.go b/dice/config.go index 1cf04a99..e3b2d5d8 100644 --- a/dice/config.go +++ b/dice/config.go @@ -2565,4 +2565,5 @@ func (d *Dice) Save(isAuto bool) { ep.StatsDump(d) } } + log.Info("自动保存完毕") } diff --git a/dice/dice_attrs_manager.go b/dice/dice_attrs_manager.go index 27faaf2d..879623e6 100644 --- a/dice/dice_attrs_manager.go +++ b/dice/dice_attrs_manager.go @@ -168,55 +168,99 @@ func (am *AttrsManager) Init(d *Dice) { // 正常工作 am.CheckForSave() am.CheckAndFreeUnused() - time.Sleep(15 * time.Second) + time.Sleep(60 * time.Second) } } }() am.cancel = cancel } -func (am *AttrsManager) CheckForSave() (int, int) { - times := 0 - saved := 0 - - db := am.db - if db == nil { +func (am *AttrsManager) CheckForSave() error { + if am.db == nil { // 尚未初始化 - return 0, 0 + return errors.New("数据库尚未初始化") } - + var resultList []*model.AttributesBatchUpsertModel + prepareToSave := map[string]int{} am.m.Range(func(key string, value *AttributesItem) bool { if !value.IsSaved { - saved += 1 - value.SaveToDB(db) + saveModel, err := value.GetBatchSaveModel() + if err != nil { + // 打印日志 + log.Errorf("定期写入用户数据出错(获取批量保存模型): %v", err) + return true + } + prepareToSave[key] = 1 + resultList = append(resultList, saveModel) } - times += 1 return true }) - return times, saved + // 整体落盘 + if len(resultList) == 0 { + log.Infof("[松子调试用]定期写入用户数据(批量保存) %v 条", len(resultList)) + return nil + } + + if err := model.AttrsPutsByIDBatch(am.db, resultList); err != nil { + log.Errorf("定期写入用户数据出错(批量保存): %v", err) + return err + } + for key := range prepareToSave { + // 理应不存在这个数据没有的情况 + v, _ := am.m.Load(key) + v.IsSaved = true + } + // 输出日志本次落盘了几个数据 + log.Infof("[松子调试用]定期写入用户数据(批量保存) %v 条", len(resultList)) + + return nil } // CheckAndFreeUnused 此函数会被定期调用,释放最近不用的对象 -func (am *AttrsManager) CheckAndFreeUnused() { - db := am.db +func (am *AttrsManager) CheckAndFreeUnused() error { + db := am.db.GetDataDB(model.WRITE) if db == nil { // 尚未初始化 - return + return errors.New("数据库尚未初始化") } prepareToFree := map[string]int{} - currentTime := time.Now().Unix() + currentTime := time.Now() + var resultList []*model.AttributesBatchUpsertModel am.m.Range(func(key string, value *AttributesItem) bool { - if value.LastUsedTime-currentTime > 60*10 { + lastUsedTime := time.Unix(value.LastUsedTime, 0) + if lastUsedTime.Sub(currentTime) > 10*time.Minute { + saveModel, err := value.GetBatchSaveModel() + if err != nil { + // 打印日志 + log.Errorf("定期清理用户数据出错(获取批量保存模型): %v", err) + return true + } prepareToFree[key] = 1 - // 直接保存 - value.SaveToDB(db) + resultList = append(resultList, saveModel) } return true }) + + // 整体落盘 + if len(resultList) == 0 { + log.Infof("[松子调试用]定期清理用户数据(批量保存) %v 条", len(resultList)) + return nil + } + + if err := model.AttrsPutsByIDBatch(am.db, resultList); err != nil { + log.Errorf("定期清理写入用户数据出错(批量保存): %v", err) + return err + } + for key := range prepareToFree { - am.m.Delete(key) + // 理应不存在这个数据没有的情况 + v, _ := am.m.LoadAndDelete(key) + v.IsSaved = true } + // 输出日志本次落盘了几个数据 + log.Infof("[松子调试用]定期清理用户数据(批量保存) %v 条", len(resultList)) + return nil } func (am *AttrsManager) CharBind(charId string, groupId string, userId string) error { @@ -292,6 +336,19 @@ func (i *AttributesItem) SaveToDB(db model.DatabaseOperator) { i.IsSaved = true } +func (i *AttributesItem) GetBatchSaveModel() (*model.AttributesBatchUpsertModel, error) { + rawData, err := ds.NewDictVal(i.valueMap).V().ToJSON() + if err != nil { + return nil, err + } + return &model.AttributesBatchUpsertModel{ + Id: i.ID, + Data: rawData, + Name: i.Name, + SheetType: i.SheetType, + }, nil +} + func (i *AttributesItem) Load(name string) *ds.VMValue { v, _ := i.valueMap.Load(name) i.LastUsedTime = time.Now().Unix() diff --git a/dice/model/attrs_new.go b/dice/model/attrs_new.go index def313e4..3fb674dc 100644 --- a/dice/model/attrs_new.go +++ b/dice/model/attrs_new.go @@ -5,9 +5,11 @@ import ( "fmt" "time" - "sealdice-core/utils" - ds "github.com/sealdice/dicescript" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "sealdice-core/utils" ) const ( @@ -141,6 +143,70 @@ func AttrsPutById(operator DatabaseOperator, id string, data []byte, name, sheet return nil // 操作成功,返回 nil } +type AttributesBatchUpsertModel struct { + Id string `json:"id"` + Data []byte `json:"data"` + Name string `json:"name"` + SheetType string `json:"sheetType"` +} + +// AttrsPutsByIDBatch 特殊入库函数 因为它 +func AttrsPutsByIDBatch(operator DatabaseOperator, saveList []*AttributesBatchUpsertModel) error { + db := operator.GetDataDB(WRITE) + now := time.Now().Unix() // 获取当前时间 + trulySaveList := make([]map[string]any, 0) + for _, singleSave := range saveList { + trulySaveList = append(trulySaveList, map[string]any{ + // 第一次全量建表 + "id": singleSave.Id, + // 使用BYTE规避无法插入的问题 + "data": BYTE(singleSave.Data), + "is_hidden": true, + "binding_sheet_id": "", + "name": singleSave.Name, + "sheet_type": singleSave.SheetType, + "created_at": now, + "updated_at": now, + }) + } + // 保守的调整一次插入1K条,这应该足够应对大部分场景,这种情况下,相当于有1K个人在60s内绑定了角色卡? + batchSize := 1000 + // TODO: 只能手动分批次插入,原因看下面 + // 由于传入的就是tx,所以这里如果插入失败,会自动回滚 + err := db.Transaction(func(tx *gorm.DB) error { + for i := 0; i < len(trulySaveList); i += batchSize { + end := i + batchSize + if end > len(trulySaveList) { + end = len(trulySaveList) + } + batch := trulySaveList[i:end] + res := tx.Debug().Clauses(clause.OnConflict{ + // 冲突列判断 + Columns: []clause.Column{ + {Name: "id"}, + }, + DoUpdates: clause.Assignments(map[string]interface{}{ + "data": clause.Column{Name: "data"}, // 更新 data 字段 + "updated_at": now, // 更新时设置 updated_at + "name": clause.Column{Name: "name"}, // 更新 name 字段 + "sheet_type": clause.Column{Name: "sheet_type"}, // 更新 sheet_type 字段 + }), + }). + Model(&AttributesItemModel{}). + // 注意! 这里有坑,不能使用CreateInBatches + map[string]interface{}。 + // CreateInBatches会设置结果接收位置为:subtx.Statement.Dest = reflectValue.Slice(i, ends).Interface() + // 指向map[string]interface{},导致数据没办法正确放入。 + // 只能用Create,同时千万别设置Create的BatchSize,否则会导致它使用上面那个函数,还是会报错。 + Create(&batch) + if res.Error != nil { + return res.Error + } + } + return nil + }) + return err +} + func AttrsDeleteById(operator DatabaseOperator, id string) error { db := operator.GetDataDB(WRITE) // 使用 GORM 的 Delete 方法删除指定 id 的记录 diff --git a/dice/model/backup.go b/dice/model/backup.go index 131da51a..4798965e 100644 --- a/dice/model/backup.go +++ b/dice/model/backup.go @@ -19,6 +19,7 @@ func Vacuum(db *gorm.DB, path string) error { } // FlushWAL 执行 WAL 日志的检查点和内存收缩 +// TODO: 在确认备份逻辑后删除该函数并收归到engine内,由engine统一做备份 func FlushWAL(db *gorm.DB) error { // 检查数据库驱动是否为 SQLite if !strings.Contains(db.Dialector.Name(), "sqlite") { diff --git a/dice/model/database/sqlite_cgo.go b/dice/model/database/sqlite_cgo.go index d07635f7..f9ee22e4 100644 --- a/dice/model/database/sqlite_cgo.go +++ b/dice/model/database/sqlite_cgo.go @@ -19,7 +19,7 @@ import ( func SQLiteDBInit(path string, useWAL bool) (*gorm.DB, error) { // 使用即时事务 - path = fmt.Sprintf("%v?_txlock=immediate&_busy_timeout=99999", path) + path = fmt.Sprintf("%v?_txlock=immediate&_busy_timeout=15000", path) open, err := gorm.Open(sqlite.Open(path), &gorm.Config{ // 注意,这里虽然是Info,但实际上打印就变成了Debug. Logger: logger.Default.LogMode(logger.Info), @@ -29,7 +29,6 @@ func SQLiteDBInit(path string, useWAL bool) (*gorm.DB, error) { } // Enable Cache Mode open, err = cache.GetOtterCacheDB(open) - // 所有优化增加 if err != nil { return nil, err } @@ -43,6 +42,8 @@ func SQLiteDBInit(path string, useWAL bool) (*gorm.DB, error) { } func createReadDB(path string, gormConf *gorm.Config) (*gorm.DB, error) { + // _txlock=immediate 解决BEGIN IMMEDIATELY + path = fmt.Sprintf("%v?_txlock=immediate", path) // ---- 创建读连接 ----- readDB, err := gorm.Open(sqlite.Open(path), gormConf) if err != nil { @@ -112,18 +113,22 @@ func SQLiteDBRWInit(path string) (*gorm.DB, *gorm.DB, error) { // https://highperformancesqlite.com/articles/sqlite-recommended-pragmas // https://litestream.io/tips/ // copied from https://github.com/bihe/monorepo +// add PRAGMA optimize=0x10002; from https://github.com/Palats/mastopoof func SetDefaultPragmas(db *sql.DB) error { var ( stmt string val string ) + // 外键的暂时弃用,反正咱也不用外键536870912 + // "foreign_keys": "1", // 1(bool) --> https://www.sqlite.org/pragma.html#pragma_foreign_keys defaultPragmas := map[string]string{ "journal_mode": "wal", // https://www.sqlite.org/pragma.html#pragma_journal_mode - "busy_timeout": "5000", // https://www.sqlite.org/pragma.html#pragma_busy_timeout - "synchronous": "1", // NORMAL --> https://www.sqlite.org/pragma.html#pragma_synchronous - "cache_size": "10000", // 10000 pages = 40MB --> https://www.sqlite.org/pragma.html#pragma_cache_size - // 外键的暂时弃用,反正咱也不用外键(乐) - // "foreign_keys": "1", // 1(bool) --> https://www.sqlite.org/pragma.html#pragma_foreign_keys + "busy_timeout": "15000", // https://www.sqlite.org/pragma.html#pragma_busy_timeout + // 在 WAL 模式下使用 synchronous=NORMAL 提交的事务可能会在断电或系统崩溃后回滚。 + // 无论同步设置或日志模式如何,事务在应用程序崩溃时都是持久的。 + // 对于在 WAL 模式下运行的大多数应用程序来说,synchronous=NORMAL 设置是一个不错的选择。 + "synchronous": "1", // NORMAL --> https://www.sqlite.org/pragma.html#pragma_synchronous + "cache_size": "536870912", // 536870912 = 512MB --> https://www.sqlite.org/pragma.html#pragma_cache_size } // set the pragmas @@ -145,6 +150,12 @@ func SetDefaultPragmas(db *sql.DB) error { return fmt.Errorf("could not set pragma %s to %s", k, defaultPragmas[k]) } } + // 这个不能在上面,因为他没有任何返回值 + // Setup some regular optimization according to sqlite doc: + // https://www.sqlite.org/lang_analyze.html + if _, err := db.Exec("PRAGMA optimize=0x10002;"); err != nil { + return fmt.Errorf("unable set optimize pragma: %w", err) + } return nil } diff --git a/dice/model/engine_interface.go b/dice/model/engine_interface.go index 3cc5edeb..565364ba 100644 --- a/dice/model/engine_interface.go +++ b/dice/model/engine_interface.go @@ -21,7 +21,7 @@ type DatabaseOperator interface { GetDataDB(mode DBMode) *gorm.DB GetLogDB(mode DBMode) *gorm.DB GetCensorDB(mode DBMode) *gorm.DB - Close() error + Close() } // 实现检查 copied from platform diff --git a/dice/model/engine_mysql.go b/dice/model/engine_mysql.go index c374bd4f..297de98a 100644 --- a/dice/model/engine_mysql.go +++ b/dice/model/engine_mysql.go @@ -14,29 +14,37 @@ import ( ) type MYSQLEngine struct { - DSN string - DB *gorm.DB - ctx context.Context + DSN string + DB *gorm.DB + dataDB *gorm.DB + logsDB *gorm.DB + censorDB *gorm.DB + ctx context.Context } -func (s *MYSQLEngine) Close() error { - //TODO implement me - panic("implement me") +func (s *MYSQLEngine) Close() { + db, err := s.DB.DB() + if err != nil { + log.Errorf("failed to close db: %v", err) + return + } + err = db.Close() + if err != nil { + log.Errorf("failed to close db: %v", err) + return + } } -func (s *MYSQLEngine) GetDataDB(mode DBMode) *gorm.DB { - //TODO implement me - panic("implement me") +func (s *MYSQLEngine) GetDataDB(_ DBMode) *gorm.DB { + return s.dataDB } -func (s *MYSQLEngine) GetLogDB(mode DBMode) *gorm.DB { - //TODO implement me - panic("implement me") +func (s *MYSQLEngine) GetLogDB(_ DBMode) *gorm.DB { + return s.logsDB } -func (s *MYSQLEngine) GetCensorDB(mode DBMode) *gorm.DB { - //TODO implement me - panic("implement me") +func (s *MYSQLEngine) GetCensorDB(_ DBMode) *gorm.DB { + return s.censorDB } type LogInfoHookMySQL struct { @@ -140,6 +148,18 @@ func (s *MYSQLEngine) Init(ctx context.Context) error { if err != nil { return err } + s.dataDB, err = s.dataDBInit() + if err != nil { + return err + } + s.logsDB, err = s.logDBInit() + if err != nil { + return err + } + s.censorDB, err = s.censorDBInit() + if err != nil { + return err + } return nil } @@ -149,7 +169,7 @@ func (s *MYSQLEngine) DBCheck() { } // DataDBInit 初始化 -func (s *MYSQLEngine) DataDBInit() (*gorm.DB, error) { +func (s *MYSQLEngine) dataDBInit() (*gorm.DB, error) { dataContext := context.WithValue(s.ctx, cache.CacheKey, cache.DataDBCacheKey) dataDB := s.DB.WithContext(dataContext) err := dataDB.AutoMigrate( @@ -166,7 +186,7 @@ func (s *MYSQLEngine) DataDBInit() (*gorm.DB, error) { return dataDB, nil } -func (s *MYSQLEngine) LogDBInit() (*gorm.DB, error) { +func (s *MYSQLEngine) logDBInit() (*gorm.DB, error) { // logs特殊建表 logsContext := context.WithValue(s.ctx, cache.CacheKey, cache.LogsDBCacheKey) logDB := s.DB.WithContext(logsContext) @@ -185,7 +205,7 @@ func (s *MYSQLEngine) LogDBInit() (*gorm.DB, error) { return logDB, nil } -func (s *MYSQLEngine) CensorDBInit() (*gorm.DB, error) { +func (s *MYSQLEngine) censorDBInit() (*gorm.DB, error) { censorContext := context.WithValue(s.ctx, cache.CacheKey, cache.CensorsDBCacheKey) censorDB := s.DB.WithContext(censorContext) if err := censorDB.AutoMigrate(&CensorLog{}); err != nil { diff --git a/dice/model/engine_pgsql.go b/dice/model/engine_pgsql.go index a1291919..60c6d3bc 100644 --- a/dice/model/engine_pgsql.go +++ b/dice/model/engine_pgsql.go @@ -10,32 +10,41 @@ import ( "sealdice-core/dice/model/database" "sealdice-core/dice/model/database/cache" + log "sealdice-core/utils/kratos" ) type PGSQLEngine struct { - DSN string - DB *gorm.DB - ctx context.Context + DSN string + DB *gorm.DB + dataDB *gorm.DB + logsDB *gorm.DB + censorDB *gorm.DB + ctx context.Context + // 其他引擎不需要读写分离 } -func (s *PGSQLEngine) Close() error { - //TODO implement me - panic("implement me") +func (s *PGSQLEngine) Close() { + db, err := s.DB.DB() + if err != nil { + log.Errorf("failed to close db: %v", err) + return + } + err = db.Close() + if err != nil { + return + } } -func (s *PGSQLEngine) GetDataDB(mode DBMode) *gorm.DB { - //TODO implement me - panic("implement me") +func (s *PGSQLEngine) GetDataDB(_ DBMode) *gorm.DB { + return s.dataDB } -func (s *PGSQLEngine) GetLogDB(mode DBMode) *gorm.DB { - //TODO implement me - panic("implement me") +func (s *PGSQLEngine) GetLogDB(_ DBMode) *gorm.DB { + return s.logsDB } -func (s *PGSQLEngine) GetCensorDB(mode DBMode) *gorm.DB { - //TODO implement me - panic("implement me") +func (s *PGSQLEngine) GetCensorDB(_ DBMode) *gorm.DB { + return s.censorDB } func (s *PGSQLEngine) Init(ctx context.Context) error { @@ -52,6 +61,19 @@ func (s *PGSQLEngine) Init(ctx context.Context) error { if err != nil { return err } + // 获取dataDB,logsDB和censorDB并赋值 + s.dataDB, err = s.dataDBInit() + if err != nil { + return err + } + s.logsDB, err = s.logDBInit() + if err != nil { + return err + } + s.censorDB, err = s.censorDBInit() + if err != nil { + return err + } return nil } @@ -61,7 +83,7 @@ func (s *PGSQLEngine) DBCheck() { } // DataDBInit 初始化 -func (s *PGSQLEngine) DataDBInit() (*gorm.DB, error) { +func (s *PGSQLEngine) dataDBInit() (*gorm.DB, error) { // data建表 dataContext := context.WithValue(s.ctx, cache.CacheKey, cache.DataDBCacheKey) dataDB := s.DB.WithContext(dataContext) @@ -78,7 +100,7 @@ func (s *PGSQLEngine) DataDBInit() (*gorm.DB, error) { return dataDB, nil } -func (s *PGSQLEngine) LogDBInit() (*gorm.DB, error) { +func (s *PGSQLEngine) logDBInit() (*gorm.DB, error) { // logs建表 logsContext := context.WithValue(s.ctx, cache.CacheKey, cache.LogsDBCacheKey) logDB := s.DB.WithContext(logsContext) @@ -88,7 +110,7 @@ func (s *PGSQLEngine) LogDBInit() (*gorm.DB, error) { return logDB, nil } -func (s *PGSQLEngine) CensorDBInit() (*gorm.DB, error) { +func (s *PGSQLEngine) censorDBInit() (*gorm.DB, error) { censorContext := context.WithValue(s.ctx, cache.CacheKey, cache.CensorsDBCacheKey) censorDB := s.DB.WithContext(censorContext) // 创建基本的表结构,并通过标签定义索引 diff --git a/dice/model/engine_sqlite.go b/dice/model/engine_sqlite.go index 6b77dfd1..11c39b30 100644 --- a/dice/model/engine_sqlite.go +++ b/dice/model/engine_sqlite.go @@ -2,27 +2,27 @@ package model import ( "context" - "database/sql" "errors" "fmt" "os" "path/filepath" "strings" + "sync" "gorm.io/gorm" "sealdice-core/dice/model/database" "sealdice-core/dice/model/database/cache" - "sealdice-core/utils" log "sealdice-core/utils/kratos" ) type SQLiteEngine struct { DataDir string ctx context.Context - // 注册到里面 - readList utils.SyncMap[dbName, *gorm.DB] - writeList utils.SyncMap[dbName, *gorm.DB] + // 用于控制readList和writeList的读写锁 + mu sync.RWMutex + readList map[dbName]*gorm.DB + writeList map[dbName]*gorm.DB } // 定义一个基于 string 的新类型 dbName @@ -34,35 +34,47 @@ const ( CensorsDBKey dbName = "censor" ) -func (s *SQLiteEngine) Close() error { - var db *sql.DB - var err error - s.readList.Range(func(key dbName, value *gorm.DB) bool { - db, err = value.DB() +func (s *SQLiteEngine) Close() { + s.mu.Lock() + defer s.mu.Unlock() + + // 关闭 readList 中的连接 + for name, db := range s.readList { + sqlDB, err := db.DB() if err != nil { - return true + log.Errorf("failed to get sql.DB for %s: %v", name, err) + continue } - err = db.Close() + if err = sqlDB.Close(); err != nil { + log.Errorf("failed to close db %s: %v", name, err) + } + } + + // 关闭 writeList 中的连接 + for name, db := range s.writeList { + sqlDB, err := db.DB() if err != nil { - return true + log.Errorf("failed to get sql.DB for %s: %v", name, err) + continue + } + if err = sqlDB.Close(); err != nil { + log.Errorf("failed to close db %s: %v", name, err) } - return true - }) - return err + } } func (s *SQLiteEngine) getDBByModeAndKey(mode DBMode, key dbName) *gorm.DB { + // 取读者锁,从而允许同时获取大量的DB + s.mu.RLock() + defer s.mu.RUnlock() switch mode { case WRITE: - db, _ := s.writeList.Load(key) - return db + return s.writeList[key] case READ: - db, _ := s.writeList.Load(key) - return db + return s.writeList[key] default: - // 默认获取写的,牺牲性能不爆炸 - db, _ := s.writeList.Load(key) - return db + // 默认获取写的,牺牲性能为代价,防止多个写 + return s.writeList[key] } } @@ -105,6 +117,9 @@ func (s *SQLiteEngine) Init(ctx context.Context) error { log.Debug("未能发现SQLITE定义位置,使用默认data地址") s.DataDir = defaultDataDir } + // map初始化 + s.readList = make(map[dbName]*gorm.DB) + s.writeList = make(map[dbName]*gorm.DB) err := s.dataDBInit() if err != nil { return err @@ -120,7 +135,6 @@ func (s *SQLiteEngine) Init(ctx context.Context) error { return nil } -// DB检查 BUG FIXME func (s *SQLiteEngine) DBCheck() { dataDir := s.DataDir checkDB := func(db *gorm.DB) bool { @@ -253,8 +267,8 @@ func (s *SQLiteEngine) dataDBInit() error { if err != nil { return err } - s.readList.Store(DataDBKey, readDB) - s.writeList.Store(DataDBKey, writeDB) + s.readList[DataDBKey] = readDB + s.writeList[DataDBKey] = writeDB return nil } @@ -287,8 +301,8 @@ func (s *SQLiteEngine) LogDBInit() error { return err } } - s.readList.Store(LogsDBKey, readDB) - s.writeList.Store(LogsDBKey, writeDB) + s.readList[LogsDBKey] = readDB + s.writeList[LogsDBKey] = writeDB return nil } @@ -309,8 +323,8 @@ func (s *SQLiteEngine) CensorDBInit() error { if err = writeDB.AutoMigrate(&CensorLog{}); err != nil { return err } - s.readList.Store(CensorsDBKey, readDB) - s.writeList.Store(CensorsDBKey, writeDB) + s.readList[CensorsDBKey] = readDB + s.writeList[CensorsDBKey] = writeDB return nil } diff --git a/main.go b/main.go index 641d82f8..a74e4e3b 100644 --- a/main.go +++ b/main.go @@ -93,11 +93,7 @@ func cleanupCreate(diceManager *dice.DiceManager) func() { for _, i := range diceManager.Dice { d := i - err = d.DBOperator.Close() - if err != nil { - // 打日志 - log.Errorf("数据库关闭出现异常 %v", err) - } + d.DBOperator.Close() } // 清理gocqhttp