2026-06-08 19:39:22 +08:00

402 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cprelation
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"strings"
"time"
)
const (
relationTypeCP = "cp"
relationTypeBrother = "brother"
relationTypeSister = "sister"
statusActive = "active"
levelCount = 5
defaultExpireHours = 24
)
var (
ErrInvalidArgument = errors.New("invalid argument")
relationTypes = []string{relationTypeCP, relationTypeBrother, relationTypeSister}
relationLabels = map[string]string{
relationTypeCP: "CP",
relationTypeBrother: "兄弟",
relationTypeSister: "姐妹",
}
defaultLevelThresholds = []int64{0, 10_000, 30_000, 60_000, 100_000}
)
type DB interface {
BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error)
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type Service struct {
db DB
}
type configDTO struct {
AppCode string `json:"appCode"`
Relations []relationDTO `json:"relations"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type relationDTO struct {
RelationType string `json:"relationType"`
DisplayName string `json:"displayName"`
MaxCountPerUser int32 `json:"maxCountPerUser"`
ApplicationExpireHours int32 `json:"applicationExpireHours"`
BreakupCostCoins int64 `json:"breakupCostCoins"`
Status string `json:"status"`
Levels []levelDTO `json:"levels"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type levelDTO struct {
Level int32 `json:"level"`
IntimacyThreshold int64 `json:"intimacyThreshold"`
RewardResourceGroupID int64 `json:"rewardResourceGroupId"`
LevelIconURL string `json:"levelIconUrl"`
Status string `json:"status"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type updateRequest struct {
Relations []relationDTO `json:"relations"`
}
func NewService(db DB) *Service {
return &Service{db: db}
}
// GetConfig 读取当前 App 的关系容量和等级阈值;缺配置时先落默认值,保证后台首次打开就是可保存状态。
func (s *Service) GetConfig(ctx context.Context, appCode string) (configDTO, error) {
nowMs := time.Now().UTC().UnixMilli()
if err := s.ensureSchema(ctx); err != nil {
return configDTO{}, err
}
if err := s.ensureDefaultConfig(ctx, appCode, nowMs); err != nil {
return configDTO{}, err
}
relations, err := s.listRelations(ctx, appCode)
if err != nil {
return configDTO{}, err
}
return configDTO{AppCode: appCode, Relations: relations, ServerTimeMS: nowMs}, nil
}
// UpdateConfig 覆盖低频配置;写入时按 relation_type + level_no upsert运行中消费逻辑每次读取最新阈值。
func (s *Service) UpdateConfig(ctx context.Context, appCode string, req updateRequest) (configDTO, error) {
nowMs := time.Now().UTC().UnixMilli()
if err := s.ensureSchema(ctx); err != nil {
return configDTO{}, err
}
relations, err := normalizeRelations(req.Relations)
if err != nil {
return configDTO{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return configDTO{}, err
}
defer func() { _ = tx.Rollback() }()
for _, relation := range relations {
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_cp_relation_configs (
app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins,
status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
max_count_per_user = VALUES(max_count_per_user),
application_expire_hours = VALUES(application_expire_hours),
breakup_cost_coins = VALUES(breakup_cost_coins),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)`,
appCode, relation.RelationType, relation.MaxCountPerUser, relation.ApplicationExpireHours, relation.BreakupCostCoins,
relation.Status, nowMs, nowMs,
); err != nil {
return configDTO{}, err
}
for _, level := range relation.Levels {
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_cp_level_rules (
app_code, relation_type, level_no, intimacy_threshold,
reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
intimacy_threshold = VALUES(intimacy_threshold),
reward_resource_group_id = VALUES(reward_resource_group_id),
level_icon_url = VALUES(level_icon_url),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)`,
appCode, relation.RelationType, level.Level, level.IntimacyThreshold,
level.RewardResourceGroupID, level.LevelIconURL, level.Status, nowMs, nowMs,
); err != nil {
return configDTO{}, err
}
}
}
if err := tx.Commit(); err != nil {
return configDTO{}, err
}
return s.GetConfig(ctx, appCode)
}
func (s *Service) listRelations(ctx context.Context, appCode string) ([]relationDTO, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins, status, updated_at_ms
FROM user_cp_relation_configs
WHERE app_code = ?`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
relationsByType := map[string]relationDTO{}
for rows.Next() {
var item relationDTO
if err := rows.Scan(&item.RelationType, &item.MaxCountPerUser, &item.ApplicationExpireHours, &item.BreakupCostCoins, &item.Status, &item.UpdatedAtMS); err != nil {
return nil, err
}
item.DisplayName = relationLabels[item.RelationType]
relationsByType[item.RelationType] = item
}
if err := rows.Err(); err != nil {
return nil, err
}
levelsByType, err := s.levelsByRelationType(ctx, appCode)
if err != nil {
return nil, err
}
for relationType, levels := range levelsByType {
item := relationsByType[relationType]
item.Levels = levels
relationsByType[relationType] = item
}
relations := make([]relationDTO, 0, len(relationTypes))
for _, relationType := range relationTypes {
item := relationsByType[relationType]
item.RelationType = relationType
item.DisplayName = relationLabels[relationType]
item.Levels = normalizeLevelDefaults(item.Levels)
relations = append(relations, item)
}
return relations, nil
}
// levelsByRelationType 必须把读取失败直接抛回 handler后台配置页不能用默认等级掩盖坏 SQL 或坏数据。
func (s *Service) levelsByRelationType(ctx context.Context, appCode string) (map[string][]levelDTO, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT relation_type, level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, updated_at_ms
FROM user_cp_level_rules
WHERE app_code = ?
ORDER BY relation_type ASC, level_no ASC`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[string][]levelDTO{}
for rows.Next() {
var relationType string
var item levelDTO
if err := rows.Scan(&relationType, &item.Level, &item.IntimacyThreshold, &item.RewardResourceGroupID, &item.LevelIconURL, &item.Status, &item.UpdatedAtMS); err != nil {
return nil, err
}
result[relationType] = append(result[relationType], item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func (s *Service) ensureDefaultConfig(ctx context.Context, appCode string, nowMs int64) error {
for _, relationType := range relationTypes {
maxCount := int32(1)
if relationType == relationTypeBrother || relationType == relationTypeSister {
maxCount = 5
}
if _, err := s.db.ExecContext(ctx, `
INSERT INTO user_cp_relation_configs (
app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins,
status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, 'active', ?, ?)
ON DUPLICATE KEY UPDATE relation_type = VALUES(relation_type)`,
appCode, relationType, maxCount, defaultExpireHours, nowMs, nowMs,
); err != nil {
return err
}
for level := int32(1); level <= levelCount; level++ {
if _, err := s.db.ExecContext(ctx, `
INSERT INTO user_cp_level_rules (
app_code, relation_type, level_no, intimacy_threshold,
reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, '', 'active', ?, ?)
ON DUPLICATE KEY UPDATE level_no = VALUES(level_no)`,
appCode, relationType, level, defaultLevelThresholds[level-1], nowMs, nowMs,
); err != nil {
return err
}
}
}
return nil
}
func (s *Service) ensureSchema(ctx context.Context) error {
if s == nil || s.db == nil {
return errors.New("user db is not configured")
}
if _, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS user_cp_relation_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
relation_type VARCHAR(32) NOT NULL,
max_count_per_user INT NOT NULL,
application_expire_hours INT NOT NULL,
breakup_cost_coins BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, relation_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); err != nil {
return err
}
if err := s.ensureColumn(ctx, "user_cp_relation_configs", "breakup_cost_coins", `
ALTER TABLE user_cp_relation_configs
ADD COLUMN breakup_cost_coins BIGINT NOT NULL DEFAULT 0 AFTER application_expire_hours`); err != nil {
return err
}
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS user_cp_level_rules (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
relation_type VARCHAR(32) NOT NULL,
level_no INT NOT NULL,
intimacy_threshold BIGINT NOT NULL,
reward_resource_group_id BIGINT NOT NULL DEFAULT 0,
level_icon_url VARCHAR(512) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, relation_type, level_no),
KEY idx_user_cp_level_rules_status (app_code, status, relation_type, level_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`)
return err
}
func (s *Service) ensureColumn(ctx context.Context, tableName string, columnName string, alterSQL string) error {
var found string
err := s.db.QueryRowContext(ctx, `
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
LIMIT 1`, tableName, columnName).Scan(&found)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
_, err = s.db.ExecContext(ctx, alterSQL)
return err
}
func normalizeRelations(input []relationDTO) ([]relationDTO, error) {
if len(input) == 0 {
return nil, fmt.Errorf("%w: 关系配置不能为空", ErrInvalidArgument)
}
seen := map[string]bool{}
out := make([]relationDTO, 0, len(relationTypes))
for _, relation := range input {
relation.RelationType = strings.ToLower(strings.TrimSpace(relation.RelationType))
if !slices.Contains(relationTypes, relation.RelationType) {
return nil, fmt.Errorf("%w: 关系类型不正确", ErrInvalidArgument)
}
if seen[relation.RelationType] {
return nil, fmt.Errorf("%w: 关系类型不能重复", ErrInvalidArgument)
}
seen[relation.RelationType] = true
// CP 关系仍是硬性一人一个;后台页面只允许调整兄弟/姐妹数量,接口层也强制收敛防止绕过前端。
if relation.RelationType == relationTypeCP {
relation.MaxCountPerUser = 1
}
if relation.MaxCountPerUser <= 0 {
return nil, fmt.Errorf("%w: %s数量必须大于0", ErrInvalidArgument, relationLabels[relation.RelationType])
}
if relation.ApplicationExpireHours <= 0 {
relation.ApplicationExpireHours = defaultExpireHours
}
if relation.BreakupCostCoins < 0 {
return nil, fmt.Errorf("%w: %s解除费用不能小于0", ErrInvalidArgument, relationLabels[relation.RelationType])
}
relation.Status = defaultString(strings.TrimSpace(relation.Status), statusActive)
relation.Levels = normalizeLevelDefaults(relation.Levels)
if err := validateLevels(relation.Levels); err != nil {
return nil, err
}
out = append(out, relation)
}
for _, relationType := range relationTypes {
if !seen[relationType] {
return nil, fmt.Errorf("%w: 缺少%s配置", ErrInvalidArgument, relationLabels[relationType])
}
}
slices.SortFunc(out, func(left, right relationDTO) int {
return slices.Index(relationTypes, left.RelationType) - slices.Index(relationTypes, right.RelationType)
})
return out, nil
}
func normalizeLevelDefaults(input []levelDTO) []levelDTO {
byLevel := map[int32]levelDTO{}
for _, level := range input {
byLevel[level.Level] = level
}
levels := make([]levelDTO, 0, levelCount)
for levelNo := int32(1); levelNo <= levelCount; levelNo++ {
level := byLevel[levelNo]
level.Level = levelNo
level.Status = defaultString(strings.TrimSpace(level.Status), statusActive)
if level.IntimacyThreshold == 0 && levelNo > 1 {
level.IntimacyThreshold = defaultLevelThresholds[levelNo-1]
}
level.LevelIconURL = strings.TrimSpace(level.LevelIconURL)
levels = append(levels, level)
}
return levels
}
func validateLevels(levels []levelDTO) error {
if len(levels) != levelCount {
return fmt.Errorf("%w: 等级必须配置1到5级", ErrInvalidArgument)
}
previous := int64(-1)
for _, level := range levels {
if level.Level < 1 || level.Level > levelCount {
return fmt.Errorf("%w: 等级只能是1到5", ErrInvalidArgument)
}
if level.IntimacyThreshold < 0 {
return fmt.Errorf("%w: 第%d级亲密值不能小于0", ErrInvalidArgument, level.Level)
}
if level.IntimacyThreshold <= previous {
return fmt.Errorf("%w: 等级亲密值必须递增", ErrInvalidArgument)
}
if level.RewardResourceGroupID < 0 {
return fmt.Errorf("%w: 奖励资源组不能小于0", ErrInvalidArgument)
}
previous = level.IntimacyThreshold
}
return nil
}
func defaultString(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}