374 lines
13 KiB
Go
374 lines
13 KiB
Go
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"`
|
||
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,
|
||
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),
|
||
status = VALUES(status),
|
||
updated_at_ms = VALUES(updated_at_ms)`,
|
||
appCode, relation.RelationType, relation.MaxCountPerUser, relation.ApplicationExpireHours,
|
||
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, 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.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,
|
||
status, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, '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,
|
||
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
|
||
}
|
||
_, 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 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
|
||
}
|
||
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)
|
||
}
|