package sqlite import ( "context" "strconv" "strings" "longheng.io/server/internal/modules/dnf/repository" ) // ReadCompanionSource 是支援兵(companion)需要的「一致只读快照」能力。 // // 为什么必须是一个快照 // 支援兵要拿源角色的角色记录 + 装备 + 技能三份数据去构造一个 AIC 投影。 // 如果这三份各自读一次,中间被一次存档写入穿插,就会拼出"装备是新的、 // 技能是旧的"这种客户端读不了的状态。所以整组读取必须在同一个读锁里完成。 // // 为什么用 component.read // Component.read 持 mu.RLock 后在 runtime 的 Group 上调 apply, // 期间所有写路径都被挡住 —— 这正是"一致快照"的实现方式,且不需要 // 另开一个只读事务(本仓储是内存态 + 异步落盘)。 // // 为什么 rollback/只读语义没有照搬 MySQL 版 // 参考实现(mysql_companion_read.go)用 sql.TxOptions{ReadOnly} 开事务, // 是因为 MySQL 版真的是逐行 SQL。本仓储的读取本身不产生写入, // 而 Clone* 只负责把聚合从调用方手里摘开,所以这里不需要 ReadOnly 包装器。 func (s *characterStore) ReadCompanionSource( ctx context.Context, accountID, ownerID, sourceID string, ) (repository.CompanionSourceState, error) { empty := repository.CompanionSourceState{} if s == nil || s.component == nil { return empty, repository.ErrCompanionReadUnavailable } if strings.TrimSpace(accountID) == "" || accountID != strings.TrimSpace(accountID) || !companionNumericID(ownerID) || !companionNumericID(sourceID) || ownerID == sourceID { return empty, repository.ErrCompanionSelection } if ctx == nil { ctx = context.Background() } if err := ctx.Err(); err != nil { return empty, err } var result repository.CompanionSourceState err := s.component.read(ctx, func(group repository.Group) error { if group.Character == nil || group.Equipment == nil || group.Skill == nil { return repository.ErrCompanionReadUnavailable } var found bool var loadErr error result.Owner, found, loadErr = group.Character.Load(ctx, ownerID) if loadErr != nil { return loadErr } if !found || result.Owner.Stats["delete_flag"] != 0 { return repository.ErrCompanionCharacterUnavailable } result.Source, found, loadErr = group.Character.Load(ctx, sourceID) if loadErr != nil { return loadErr } if !found || result.Source.Stats["delete_flag"] != 0 { return repository.ErrCompanionCharacterUnavailable } // 两个角色都必须在同一个账号名下:支援兵是"借用自己账号的另一个角色", // 跨账号借用会让借用人的装备/技能快照泄漏给别人。 if result.Owner.AccountID != accountID || result.Source.AccountID != accountID { return repository.ErrCompanionAccountMismatch } result.Equipment, result.HasEquipment, loadErr = group.Equipment.Load(ctx, sourceID) if loadErr != nil { return loadErr } result.Skill, result.HasSkill, loadErr = group.Skill.Load(ctx, sourceID) if loadErr != nil { return loadErr } return nil }) if err != nil { return empty, err } // 摘开聚合:调用方随后会在自己的 goroutine 里读这些记录, // 不能让它和仓储内部的可变状态共享 map/切片。 result.Owner = repository.CloneCharacter(result.Owner) result.Source = repository.CloneCharacter(result.Source) result.Equipment = repository.CloneEquipment(result.Equipment) result.Skill = repository.CloneSkill(result.Skill) return result, nil } // companionNumericID 要求是规范的十进制 uint16(不含前导零/符号/空白), // 与 MySQL 版保持同一套校验,避免 "007" 与 "7" 被当成两个不同角色。 func companionNumericID(value string) bool { id, err := strconv.ParseUint(value, 10, 16) return err == nil && id != 0 && strconv.FormatUint(id, 10) == value } var _ repository.CompanionSourceReader = (*characterStore)(nil)