服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
514 lines
13 KiB
Go
514 lines
13 KiB
Go
package notice
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
mysql "github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
type sqlNoticeQuerier interface {
|
|
QueryContext(context.Context, string, ...any) (SQLRows, error)
|
|
}
|
|
|
|
type sqlNoticeExecutor interface {
|
|
sqlNoticeQuerier
|
|
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
|
}
|
|
|
|
func (s *SQLStore) lookupPublishKey(ctx context.Context, db sqlNoticeQuerier, lookupKey string) (string, bool, error) {
|
|
return s.lookupKey(ctx, db, "SELECT notice_id FROM "+quoteSQLIdentifier(s.publish)+" WHERE lookup_key = ? LIMIT 1", lookupKey)
|
|
}
|
|
|
|
func (s *SQLStore) lookupAcknowledgeKey(ctx context.Context, db sqlNoticeQuerier, lookupKey string) (string, bool, error) {
|
|
return s.lookupKey(ctx, db, "SELECT delivery_id FROM "+quoteSQLIdentifier(s.ack)+" WHERE lookup_key = ? LIMIT 1", lookupKey)
|
|
}
|
|
|
|
func (s *SQLStore) lookupKey(ctx context.Context, db sqlNoticeQuerier, query string, lookupKey string) (string, bool, error) {
|
|
rows, err := db.QueryContext(ctx, query, lookupKey)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
values, err := scanSQLStrings(rows)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if len(values) == 0 {
|
|
return "", false, nil
|
|
}
|
|
return values[0], true, nil
|
|
}
|
|
|
|
func (s *SQLStore) getNotice(ctx context.Context, db sqlNoticeQuerier, id string) (Notice, bool, error) {
|
|
notice, _, ok, err := s.getNoticePubAt(ctx, db, id)
|
|
return notice, ok, err
|
|
}
|
|
|
|
func (s *SQLStore) getNoticePubAt(ctx context.Context, db sqlNoticeQuerier, id string) (Notice, time.Time, bool, error) {
|
|
rows, err := db.QueryContext(ctx, "SELECT "+noticeColumns()+" FROM "+quoteSQLIdentifier(s.notices)+" WHERE id = ? LIMIT 1", id)
|
|
if err != nil {
|
|
return Notice{}, time.Time{}, false, err
|
|
}
|
|
notices, publishedAt, err := scanSQLPubNotices(rows)
|
|
if err != nil {
|
|
return Notice{}, time.Time{}, false, err
|
|
}
|
|
if len(notices) == 0 {
|
|
return Notice{}, time.Time{}, false, nil
|
|
}
|
|
return cloneNotice(notices[0]), publishedAt[0], true, nil
|
|
}
|
|
|
|
func (s *SQLStore) getDelivery(ctx context.Context, db sqlNoticeQuerier, id string) (Delivery, bool, error) {
|
|
rows, err := db.QueryContext(ctx, "SELECT "+deliveryColumns()+" FROM "+quoteSQLIdentifier(s.deliveries)+" WHERE id = ? LIMIT 1", id)
|
|
if err != nil {
|
|
return Delivery{}, false, err
|
|
}
|
|
deliveries, err := scanSQLDeliveries(rows)
|
|
if err != nil {
|
|
return Delivery{}, false, err
|
|
}
|
|
if len(deliveries) == 0 {
|
|
return Delivery{}, false, nil
|
|
}
|
|
return cloneDelivery(deliveries[0]), true, nil
|
|
}
|
|
|
|
func (s *SQLStore) deliveriesForNotice(ctx context.Context, db sqlNoticeQuerier, noticeID string) ([]Delivery, error) {
|
|
rows, err := db.QueryContext(ctx, "SELECT "+deliveryColumns()+" FROM "+quoteSQLIdentifier(s.deliveries)+" WHERE notice_id = ? ORDER BY account_id ASC, id ASC", noticeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
deliveries, err := scanSQLDeliveries(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sortDeliveries(deliveries)
|
|
return deliveries, nil
|
|
}
|
|
|
|
func (s *SQLStore) publishMeta(ctx context.Context, db sqlNoticeQuerier, lookupKey string) (map[string]string, error) {
|
|
rows, err := db.QueryContext(ctx, "SELECT meta_json FROM "+quoteSQLIdentifier(s.publish)+" WHERE lookup_key = ? LIMIT 1", lookupKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values, err := scanSQLNullStrings(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(values) == 0 {
|
|
return nil, nil
|
|
}
|
|
return decodeSQLStringMap(values[0])
|
|
}
|
|
|
|
func (s *SQLStore) insertNotice(ctx context.Context, db sqlNoticeExecutor, notice Notice, publishedAt time.Time) error {
|
|
meta, err := encodeSQLStringMap(notice.Meta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.ExecContext(ctx,
|
|
"INSERT INTO "+quoteSQLIdentifier(s.notices)+" (id, kind, scope, shard_id, title, body, attachment_ref, starts_at, ends_at, disabled, meta_json, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
notice.ID,
|
|
notice.Kind,
|
|
notice.Scope,
|
|
notice.ShardID,
|
|
notice.Title,
|
|
nullableSQLString(notice.Body),
|
|
notice.AttachmentRef,
|
|
nullableSQLTime(notice.StartsAt),
|
|
nullableSQLTime(notice.EndsAt),
|
|
notice.Disabled,
|
|
meta,
|
|
publishedAt.UTC(),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLStore) insertDelivery(ctx context.Context, db sqlNoticeExecutor, delivery Delivery) error {
|
|
meta, err := encodeSQLStringMap(delivery.Meta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.ExecContext(ctx,
|
|
"INSERT INTO "+quoteSQLIdentifier(s.deliveries)+" (id, notice_id, account_id, shard_id, status, attachment_ref, idempotency_key, meta_json, created_at, acknowledged_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
delivery.ID,
|
|
delivery.NoticeID,
|
|
delivery.AccountID,
|
|
delivery.ShardID,
|
|
delivery.Status,
|
|
delivery.AttachmentRef,
|
|
delivery.IdempotencyKey,
|
|
meta,
|
|
delivery.CreatedAt.UTC(),
|
|
nullableSQLTime(delivery.AcknowledgedAt),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLStore) insertPublishKey(ctx context.Context, db sqlNoticeExecutor, lookupKey string, noticeID string, idempotencyKey string, meta map[string]string, createdAt time.Time) error {
|
|
metaJSON, err := encodeSQLStringMap(meta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.ExecContext(ctx,
|
|
"INSERT INTO "+quoteSQLIdentifier(s.publish)+" (lookup_key, notice_id, idempotency_key, meta_json, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
lookupKey,
|
|
noticeID,
|
|
idempotencyKey,
|
|
metaJSON,
|
|
createdAt.UTC(),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLStore) insertAcknowledgeKey(ctx context.Context, db sqlNoticeExecutor, lookupKey string, deliveryID string, idempotencyKey string, createdAt time.Time) error {
|
|
_, err := db.ExecContext(ctx,
|
|
"INSERT INTO "+quoteSQLIdentifier(s.ack)+" (lookup_key, delivery_id, idempotency_key, created_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE delivery_id = delivery_id",
|
|
lookupKey,
|
|
deliveryID,
|
|
idempotencyKey,
|
|
createdAt.UTC(),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLStore) updateDeliveryAck(ctx context.Context, db sqlNoticeExecutor, delivery Delivery) error {
|
|
result, err := db.ExecContext(ctx,
|
|
"UPDATE "+quoteSQLIdentifier(s.deliveries)+" SET status = ?, acknowledged_at = ? WHERE id = ?",
|
|
delivery.Status,
|
|
nullableSQLTime(delivery.AcknowledgedAt),
|
|
delivery.ID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrDeliveryNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLStore) updateDeliveryStatus(ctx context.Context, db sqlNoticeExecutor, deliveryID string, status string) error {
|
|
result, err := db.ExecContext(ctx,
|
|
"UPDATE "+quoteSQLIdentifier(s.deliveries)+" SET status = ? WHERE id = ?",
|
|
status,
|
|
deliveryID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrDeliveryNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLStore) queryStringColumn(ctx context.Context, query string, args ...any) ([]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanSQLStrings(rows)
|
|
}
|
|
|
|
func scanSQLNotices(rows SQLRows) ([]Notice, error) {
|
|
notices, _, err := scanSQLPubNotices(rows)
|
|
return notices, err
|
|
}
|
|
|
|
func scanSQLPubNotices(rows SQLRows) (notices []Notice, published []time.Time, err error) {
|
|
if rows == nil {
|
|
return nil, nil, ErrNoticeInvalidRows
|
|
}
|
|
defer closeNoticeRowsErr(rows, &err)
|
|
for rows.Next() {
|
|
notice, publishedAt, err := scanSQLNotice(rows)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
notices = append(notices, notice)
|
|
published = append(published, publishedAt)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return notices, published, nil
|
|
}
|
|
|
|
func scanSQLNotice(rows SQLRows) (Notice, time.Time, error) {
|
|
var notice Notice
|
|
var scope, shardID, body, attachmentRef, meta sql.NullString
|
|
var startsAt, endsAt sql.NullTime
|
|
var publishedAt time.Time
|
|
if err := rows.Scan(
|
|
¬ice.ID,
|
|
¬ice.Kind,
|
|
&scope,
|
|
&shardID,
|
|
¬ice.Title,
|
|
&body,
|
|
&attachmentRef,
|
|
&startsAt,
|
|
&endsAt,
|
|
¬ice.Disabled,
|
|
&meta,
|
|
&publishedAt,
|
|
); err != nil {
|
|
return Notice{}, time.Time{}, err
|
|
}
|
|
if scope.Valid {
|
|
notice.Scope = scope.String
|
|
}
|
|
if shardID.Valid {
|
|
notice.ShardID = shardID.String
|
|
}
|
|
if body.Valid {
|
|
notice.Body = body.String
|
|
}
|
|
if attachmentRef.Valid {
|
|
notice.AttachmentRef = attachmentRef.String
|
|
}
|
|
if startsAt.Valid {
|
|
notice.StartsAt = startsAt.Time.UTC()
|
|
}
|
|
if endsAt.Valid {
|
|
notice.EndsAt = endsAt.Time.UTC()
|
|
}
|
|
if meta.Valid {
|
|
decoded, err := decodeSQLStringMap(meta.String)
|
|
if err != nil {
|
|
return Notice{}, time.Time{}, err
|
|
}
|
|
notice.Meta = decoded
|
|
}
|
|
return cloneNotice(notice), publishedAt.UTC(), nil
|
|
}
|
|
|
|
func scanSQLDeliveries(rows SQLRows) (deliveries []Delivery, err error) {
|
|
if rows == nil {
|
|
return nil, ErrNoticeInvalidRows
|
|
}
|
|
defer closeNoticeRowsErr(rows, &err)
|
|
for rows.Next() {
|
|
delivery, err := scanSQLDelivery(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
deliveries = append(deliveries, delivery)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return deliveries, nil
|
|
}
|
|
|
|
func scanSQLDelivery(rows SQLRows) (Delivery, error) {
|
|
var delivery Delivery
|
|
var shardID, attachmentRef, idempotencyKey, meta sql.NullString
|
|
var acknowledgedAt sql.NullTime
|
|
if err := rows.Scan(
|
|
&delivery.ID,
|
|
&delivery.NoticeID,
|
|
&delivery.AccountID,
|
|
&shardID,
|
|
&delivery.Status,
|
|
&attachmentRef,
|
|
&idempotencyKey,
|
|
&meta,
|
|
&delivery.CreatedAt,
|
|
&acknowledgedAt,
|
|
); err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
if shardID.Valid {
|
|
delivery.ShardID = shardID.String
|
|
}
|
|
if attachmentRef.Valid {
|
|
delivery.AttachmentRef = attachmentRef.String
|
|
}
|
|
if idempotencyKey.Valid {
|
|
delivery.IdempotencyKey = idempotencyKey.String
|
|
}
|
|
if meta.Valid {
|
|
decoded, err := decodeSQLStringMap(meta.String)
|
|
if err != nil {
|
|
return Delivery{}, err
|
|
}
|
|
delivery.Meta = decoded
|
|
}
|
|
delivery.CreatedAt = delivery.CreatedAt.UTC()
|
|
if acknowledgedAt.Valid {
|
|
delivery.AcknowledgedAt = acknowledgedAt.Time.UTC()
|
|
}
|
|
return cloneDelivery(delivery), nil
|
|
}
|
|
|
|
func scanSQLStrings(rows SQLRows) ([]string, error) {
|
|
values, err := scanSQLNullStrings(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
out = append(out, strings.TrimSpace(value))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func scanSQLNullStrings(rows SQLRows) (values []string, err error) {
|
|
if rows == nil {
|
|
return nil, ErrNoticeInvalidRows
|
|
}
|
|
defer closeNoticeRowsErr(rows, &err)
|
|
for rows.Next() {
|
|
var value sql.NullString
|
|
if err := rows.Scan(&value); err != nil {
|
|
return nil, err
|
|
}
|
|
if value.Valid {
|
|
values = append(values, value.String)
|
|
} else {
|
|
values = append(values, "")
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// closeNoticeRowsErr 在扫描辅助函数返回前合并 Close 错误,避免查询清理失败被静默吞掉。
|
|
func closeNoticeRowsErr(rows SQLRows, err *error) {
|
|
if rows == nil || err == nil {
|
|
return
|
|
}
|
|
if closeErr := rows.Close(); closeErr != nil && *err == nil {
|
|
*err = closeErr
|
|
}
|
|
}
|
|
|
|
func noticeColumns() string {
|
|
return "id, kind, scope, shard_id, title, body, attachment_ref, starts_at, ends_at, disabled, meta_json, published_at"
|
|
}
|
|
|
|
func deliveryColumns() string {
|
|
return "id, notice_id, account_id, shard_id, status, attachment_ref, idempotency_key, meta_json, created_at, acknowledged_at"
|
|
}
|
|
|
|
func encodeSQLStringMap(values map[string]string) (any, error) {
|
|
if len(values) == 0 {
|
|
return nil, nil
|
|
}
|
|
data, err := json.Marshal(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func decodeSQLStringMap(value string) (map[string]string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
var out map[string]string
|
|
if err := json.Unmarshal([]byte(value), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return normalizeStringMap(out), nil
|
|
}
|
|
|
|
func nullableSQLString(value string) any {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func nullableSQLTime(value time.Time) any {
|
|
if value.IsZero() {
|
|
return nil
|
|
}
|
|
return value.UTC()
|
|
}
|
|
|
|
type sqlStoreTables struct {
|
|
notices string
|
|
deliveries string
|
|
publish string
|
|
ack string
|
|
}
|
|
|
|
func normSQLStoreTables(options SQLStoreOptions) (sqlStoreTables, error) {
|
|
prefix := strings.TrimSpace(options.TablePrefix)
|
|
if prefix == "" {
|
|
prefix = defSQLTablePrefix
|
|
}
|
|
tables := sqlStoreTables{
|
|
notices: firstNonEmpty(options.NoticeTable, prefix+"_notices"),
|
|
deliveries: firstNonEmpty(options.DeliveryTable, prefix+"_deliveries"),
|
|
publish: firstNonEmpty(options.PublishKeyTable, prefix+"_publish_keys"),
|
|
ack: firstNonEmpty(options.AcknowledgeKeyTable, prefix+"_ack_keys"),
|
|
}
|
|
for _, table := range []string{tables.notices, tables.deliveries, tables.publish, tables.ack} {
|
|
if !noticeTablePattern.MatchString(table) {
|
|
return sqlStoreTables{}, ErrNoticeInvalidTable
|
|
}
|
|
}
|
|
return tables, nil
|
|
}
|
|
|
|
func quoteSQLIdentifier(identifier string) string {
|
|
return "`" + identifier + "`"
|
|
}
|
|
|
|
func sqlLookupKey(raw string) string {
|
|
sum := sha256.Sum256([]byte(raw))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func sqlContext(ctx context.Context) context.Context {
|
|
if ctx == nil {
|
|
return context.Background()
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func sqlRecoveryCtx(parent context.Context) (context.Context, context.CancelFunc) {
|
|
base := context.Background()
|
|
if parent != nil {
|
|
base = context.WithoutCancel(parent)
|
|
}
|
|
return context.WithTimeout(base, rollbackTimeout)
|
|
}
|
|
|
|
func isNoticeSQLDuplicate(err error) bool {
|
|
var mysqlErr *mysql.MySQLError
|
|
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
|
}
|
|
|
|
var noticeTablePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|