服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
426 lines
12 KiB
Go
426 lines
12 KiB
Go
package player
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
const mysqlProfileTable = "player_profiles"
|
|
|
|
var errProfileRowsReq = errors.New("mysql profile rows are required")
|
|
|
|
// MySQLStoreOptions 是 MySQL 玩家 Profile 存储的连接池、表名和建表配置。
|
|
type MySQLStoreOptions struct {
|
|
DSN string
|
|
TableName string
|
|
MaxOpenConns int
|
|
MaxIdleConns int
|
|
ConnMaxLifetime time.Duration
|
|
EnsureSchema bool
|
|
}
|
|
|
|
// MySQLStore 基于 MySQL JSON 列保存玩家 Profile 的字段化数据。
|
|
type MySQLStore struct {
|
|
db *sql.DB
|
|
initErr error
|
|
tableName string
|
|
ensureSchema bool
|
|
}
|
|
|
|
// NewMySQLStore 创建 MySQL 玩家 Profile 存储。
|
|
func NewMySQLStore(options MySQLStoreOptions) *MySQLStore {
|
|
options = normMySQLStoreOpts(options)
|
|
conn, err := sql.Open("mysql", options.DSN)
|
|
if err == nil {
|
|
conn.SetMaxOpenConns(options.MaxOpenConns)
|
|
conn.SetMaxIdleConns(options.MaxIdleConns)
|
|
conn.SetConnMaxLifetime(options.ConnMaxLifetime)
|
|
}
|
|
return &MySQLStore{
|
|
db: conn,
|
|
initErr: err,
|
|
tableName: options.TableName,
|
|
ensureSchema: options.EnsureSchema,
|
|
}
|
|
}
|
|
|
|
func normMySQLStoreOpts(options MySQLStoreOptions) MySQLStoreOptions {
|
|
options.DSN = strings.TrimSpace(options.DSN)
|
|
if options.DSN == "" {
|
|
options.DSN = "longheng:longheng@tcp(127.0.0.1:3306)/longheng?parseTime=true&charset=utf8mb4,utf8"
|
|
}
|
|
options.TableName = strings.TrimSpace(options.TableName)
|
|
if options.TableName == "" {
|
|
options.TableName = mysqlProfileTable
|
|
}
|
|
if options.MaxOpenConns <= 0 {
|
|
options.MaxOpenConns = 32
|
|
}
|
|
if options.MaxIdleConns <= 0 {
|
|
options.MaxIdleConns = 8
|
|
}
|
|
if options.ConnMaxLifetime <= 0 {
|
|
options.ConnMaxLifetime = 5 * time.Minute
|
|
}
|
|
return options
|
|
}
|
|
|
|
// Load 按账号 ID 从 MySQL 读取玩家 Profile。
|
|
func (s *MySQLStore) Load(ctx context.Context, accountID string) (Profile, bool, error) {
|
|
if err := s.ready(ctx); err != nil {
|
|
return Profile{}, false, err
|
|
}
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
return Profile{}, false, fmt.Errorf("account id is required")
|
|
}
|
|
query := fmt.Sprintf( // #nosec G201 -- 表名和列名均由框架固定常量或 mysqlQuoteIdentifier 生成,业务值仍使用占位符。
|
|
"SELECT %s FROM %s WHERE %s = ?",
|
|
mysqlSelectColumns(),
|
|
mysqlQuoteIdentifier(s.tableName),
|
|
mysqlQuoteIdentifier("account_id"),
|
|
)
|
|
return s.loadProfile(ctx, query, accountID)
|
|
}
|
|
|
|
// LoadByRoleID 按角色 ID 从 MySQL 读取玩家 Profile。
|
|
func (s *MySQLStore) LoadByRoleID(ctx context.Context, roleID string) (Profile, bool, error) {
|
|
if err := s.ready(ctx); err != nil {
|
|
return Profile{}, false, err
|
|
}
|
|
roleID = strings.TrimSpace(roleID)
|
|
if roleID == "" {
|
|
return Profile{}, false, fmt.Errorf("role id is required")
|
|
}
|
|
query := fmt.Sprintf( // #nosec G201 -- 表名、列名和值占位符均由框架白名单字段生成,业务值通过参数绑定写入。
|
|
"SELECT %s FROM %s WHERE %s = ? LIMIT 1",
|
|
mysqlSelectColumns(),
|
|
mysqlQuoteIdentifier(s.tableName),
|
|
mysqlQuoteIdentifier("role_id"),
|
|
)
|
|
return s.loadProfile(ctx, query, roleID)
|
|
}
|
|
|
|
// ListProfiles 按账号 ID 游标从 MySQL 扫描玩家 Profile。
|
|
func (s *MySQLStore) ListProfiles(ctx context.Context, afterAccountID string, limit int) (out []Profile, err error) {
|
|
if err := s.ready(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
return nil, nil
|
|
}
|
|
if limit > 1000 {
|
|
limit = 1000
|
|
}
|
|
afterAccountID = strings.TrimSpace(afterAccountID)
|
|
query := fmt.Sprintf( //nolint:gosec // G201:表名和列名均由框架固定常量或 mysqlQuoteIdentifier 生成,业务值仍使用占位符。
|
|
"SELECT %s FROM %s WHERE %s > ? ORDER BY %s LIMIT ?",
|
|
mysqlSelectColumns(),
|
|
mysqlQuoteIdentifier(s.tableName),
|
|
mysqlQuoteIdentifier("account_id"),
|
|
mysqlQuoteIdentifier("account_id"),
|
|
)
|
|
rows, err := s.db.QueryContext(ctx, query, afterAccountID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rows == nil {
|
|
return nil, errProfileRowsReq
|
|
}
|
|
defer closeMySQLRowsErr(rows, &err)
|
|
|
|
out = make([]Profile, 0, limit)
|
|
for rows.Next() {
|
|
profile, err := scanMySQLProfile(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, profile)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func closeMySQLRowsErr(rows interface{ Close() error }, err *error) {
|
|
if rows == nil || err == nil {
|
|
return
|
|
}
|
|
if closeErr := rows.Close(); closeErr != nil && *err == nil {
|
|
*err = closeErr
|
|
}
|
|
}
|
|
|
|
func (s *MySQLStore) loadProfile(ctx context.Context, query string, args ...any) (Profile, bool, error) {
|
|
profile, err := scanMySQLProfile(s.db.QueryRowContext(ctx, query, args...))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Profile{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Profile{}, false, err
|
|
}
|
|
return profile, true, nil
|
|
}
|
|
|
|
type profileScanner interface {
|
|
Scan(...any) error
|
|
}
|
|
|
|
func scanMySQLProfile(scanner profileScanner) (Profile, error) {
|
|
var accountID, roleID string
|
|
moduleFields := redisModuleFields()
|
|
moduleValues := make([]sql.NullString, len(moduleFields))
|
|
dest := make([]any, 0, 2+len(moduleFields))
|
|
dest = append(dest, &accountID, &roleID)
|
|
for idx := range moduleValues {
|
|
dest = append(dest, &moduleValues[idx])
|
|
}
|
|
err := scanner.Scan(dest...)
|
|
if err != nil {
|
|
return Profile{}, err
|
|
}
|
|
modules := make(map[string][]byte, len(moduleFields))
|
|
for idx, field := range moduleFields {
|
|
modules[field] = nullableJSON(moduleValues[idx])
|
|
}
|
|
baseField, ok := profileDBHashField(ProfileFieldBase)
|
|
if !ok {
|
|
return Profile{}, fmt.Errorf("profile base field is not configured")
|
|
}
|
|
if len(modules[baseField]) == 0 {
|
|
return Profile{}, fmt.Errorf("profile %s has no base module", accountID)
|
|
}
|
|
|
|
profile, err := profileFromRedis(accountID, modules)
|
|
if err != nil {
|
|
return Profile{}, err
|
|
}
|
|
if profile.RoleID == "" {
|
|
profile.RoleID = roleID
|
|
}
|
|
profile, err = normMySQLIdentity(profile)
|
|
if err != nil {
|
|
return Profile{}, err
|
|
}
|
|
return cloneProfile(profile), nil
|
|
}
|
|
|
|
// Save 保存完整玩家 Profile 到 MySQL。
|
|
func (s *MySQLStore) Save(ctx context.Context, profile Profile) error {
|
|
return s.SaveFields(ctx, profile, AllProfileFields()...)
|
|
}
|
|
|
|
// SaveFields 只保存玩家 Profile 的指定字段,必要时补写基础字段。
|
|
func (s *MySQLStore) SaveFields(ctx context.Context, profile Profile, fields ...ProfileField) error {
|
|
if err := s.ready(ctx); err != nil {
|
|
return err
|
|
}
|
|
profile, err := normMySQLIdentity(profile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fields = normProfileFields(fields)
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
encoded, err := encodeProfileFields(profile, fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dirty := make(map[ProfileField]struct{}, len(fields))
|
|
for _, field := range fields {
|
|
dirty[field] = struct{}{}
|
|
}
|
|
if _, ok := dirty[ProfileFieldBase]; !ok {
|
|
baseData, err := encodeProfileField(profile, ProfileFieldBase)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encoded[redisBaseField] = baseData
|
|
}
|
|
|
|
updatedAt := profile.UpdatedAt
|
|
if updatedAt.IsZero() {
|
|
updatedAt = time.Now().UTC()
|
|
}
|
|
columns := []string{
|
|
mysqlQuoteIdentifier("account_id"),
|
|
mysqlQuoteIdentifier("role_id"),
|
|
mysqlQuoteIdentifier("updated_at"),
|
|
}
|
|
args := []any{profile.AccountID, profile.RoleID, updatedAt}
|
|
updates := []string{mysqlQuoteIdentifier("updated_at") + " = VALUES(" + mysqlQuoteIdentifier("updated_at") + ")"}
|
|
insertFields := fields
|
|
if _, ok := dirty[ProfileFieldBase]; !ok {
|
|
insertFields = append([]ProfileField{ProfileFieldBase}, fields...)
|
|
}
|
|
for _, field := range insertFields {
|
|
hashField, ok := profileDBHashField(field)
|
|
if !ok {
|
|
return fmt.Errorf("unsupported profile field %q", field)
|
|
}
|
|
column, ok := mysqlProfileColumn(hashField)
|
|
if !ok {
|
|
return fmt.Errorf("unsupported mysql profile field %q", hashField)
|
|
}
|
|
column = mysqlQuoteIdentifier(column)
|
|
columns = append(columns, column)
|
|
args = append(args, string(encoded[hashField]))
|
|
if _, shouldUpdate := dirty[field]; shouldUpdate {
|
|
if field == ProfileFieldBase {
|
|
roleIDColumn := mysqlQuoteIdentifier("role_id")
|
|
updates = append(updates, roleIDColumn+" = VALUES("+roleIDColumn+")")
|
|
}
|
|
updates = append(updates, column+" = VALUES("+column+")")
|
|
} else if field == ProfileFieldBase {
|
|
roleIDColumn := mysqlQuoteIdentifier("role_id")
|
|
updates = append(updates,
|
|
roleIDColumn+" = IF("+roleIDColumn+" = '', VALUES("+roleIDColumn+"), "+roleIDColumn+")",
|
|
column+" = COALESCE("+column+", VALUES("+column+"))",
|
|
)
|
|
}
|
|
}
|
|
|
|
valueMarkers := make([]string, len(columns))
|
|
for i := range valueMarkers {
|
|
valueMarkers[i] = "?"
|
|
}
|
|
query := fmt.Sprintf( //nolint:gosec // G201:表名、列名和值占位符均由框架白名单字段生成,业务值通过参数绑定写入。
|
|
"INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s",
|
|
mysqlQuoteIdentifier(s.tableName),
|
|
strings.Join(columns, ", "),
|
|
strings.Join(valueMarkers, ", "),
|
|
strings.Join(updates, ", "),
|
|
)
|
|
_, err = s.db.ExecContext(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
func normMySQLIdentity(profile Profile) (Profile, error) {
|
|
profile = normProfileID(profile)
|
|
if profile.AccountID == "" {
|
|
return Profile{}, fmt.Errorf("account id is required")
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
// Check 检查 MySQL 连接,并按配置确保 Profile 表存在。
|
|
func (s *MySQLStore) Check(ctx context.Context) error {
|
|
if err := s.ready(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := s.db.PingContext(ctx); err != nil {
|
|
return err
|
|
}
|
|
if s.ensureSchema {
|
|
_, err := s.db.ExecContext(ctx, mysqlProfileTableDDL(s.tableName))
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close 关闭 MySQL 玩家 Profile 存储连接。
|
|
func (s *MySQLStore) Close(context.Context) error {
|
|
if s == nil || s.db == nil {
|
|
return nil
|
|
}
|
|
return s.db.Close()
|
|
}
|
|
|
|
func (s *MySQLStore) ready(ctx context.Context) error {
|
|
ctx = contextOrBackground(ctx)
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if s == nil || s.db == nil {
|
|
return fmt.Errorf("mysql store is nil")
|
|
}
|
|
return s.initErr
|
|
}
|
|
|
|
// TableName 返回当前 MySQL 玩家 Profile 表名。
|
|
func (s *MySQLStore) TableName() string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return s.tableName
|
|
}
|
|
|
|
func mysqlProfileColumn(hashField string) (string, bool) {
|
|
if _, ok := profileDBDescByHash(hashField); !ok {
|
|
return "", false
|
|
}
|
|
return mysqlJSONColumn(hashField), true
|
|
}
|
|
|
|
func mysqlProfileTableDDL(tableName string) string {
|
|
tableName = strings.TrimSpace(tableName)
|
|
if tableName == "" {
|
|
tableName = mysqlProfileTable
|
|
}
|
|
columnDefs := []string{
|
|
mysqlQuoteIdentifier("account_id") + " VARCHAR(128) NOT NULL",
|
|
mysqlQuoteIdentifier("role_id") + " VARCHAR(128) NOT NULL DEFAULT ''",
|
|
}
|
|
for _, column := range mysqlJSONColumns() {
|
|
columnDefs = append(columnDefs, mysqlQuoteIdentifier(column)+" JSON NULL")
|
|
}
|
|
columnDefs = append(columnDefs,
|
|
mysqlQuoteIdentifier("updated_at")+" DATETIME(6) NOT NULL",
|
|
"PRIMARY KEY ("+mysqlQuoteIdentifier("account_id")+")",
|
|
"UNIQUE KEY "+mysqlQuoteIdentifier("uk_player_profiles_role_id")+" ("+mysqlQuoteIdentifier("role_id")+")",
|
|
"KEY "+mysqlQuoteIdentifier("idx_player_profiles_updated_at")+" ("+mysqlQuoteIdentifier("updated_at")+")",
|
|
)
|
|
return fmt.Sprintf(
|
|
"CREATE TABLE IF NOT EXISTS %s (\n %s\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
|
mysqlQuoteIdentifier(tableName),
|
|
strings.Join(columnDefs, ",\n "),
|
|
)
|
|
}
|
|
|
|
func mysqlSelectColumns() string {
|
|
columns := mysqlProfileCols()
|
|
out := make([]string, len(columns))
|
|
for idx, column := range columns {
|
|
out[idx] = mysqlQuoteIdentifier(column)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
func mysqlProfileCols() []string {
|
|
columns := []string{"account_id", "role_id"}
|
|
columns = append(columns, mysqlJSONColumns()...)
|
|
return columns
|
|
}
|
|
|
|
func mysqlJSONColumns() []string {
|
|
hashFields := redisModuleFields()
|
|
columns := make([]string, 0, len(hashFields))
|
|
for _, hashField := range hashFields {
|
|
columns = append(columns, mysqlJSONColumn(hashField))
|
|
}
|
|
return columns
|
|
}
|
|
|
|
func mysqlJSONColumn(hashField string) string {
|
|
return hashField + "_json"
|
|
}
|
|
|
|
func mysqlQuoteIdentifier(identifier string) string {
|
|
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
|
|
}
|
|
|
|
func nullableJSON(value sql.NullString) []byte {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
return []byte(value.String)
|
|
}
|