fix(user): make agency invitations retry-safe

This commit is contained in:
zhx 2026-07-20 18:30:56 +08:00
parent c9d8c75d59
commit 1c45fdc65b
3 changed files with 53 additions and 0 deletions

View File

@ -314,6 +314,20 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
} else if ok {
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency")
}
// 后台直建 Agency 是对角色归属的最终确认;目标先前收到的 Agency 邀请已经不可再接受。
// 必须在同一事务关闭 pending 消息,否则消息中心会长期展示一个点击后必然冲突的旧邀请。
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeAgency, command.OwnerUserID, true); err != nil {
return hostdomain.CreateAgencyResult{}, err
} else if ok {
pending.Status = hostdomain.InvitationStatusCancelled
pending.ProcessedByUserID = command.AdminUserID
pending.ProcessReason = "superseded by admin agency creation"
pending.ProcessedAtMs = command.NowMs
pending.UpdatedAtMs = command.NowMs
if err := updateRoleInvitationProcessed(ctx, tx, pending); err != nil {
return hostdomain.CreateAgencyResult{}, err
}
}
activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, command.OwnerUserID, true)
if err != nil {
return hostdomain.CreateAgencyResult{}, err

View File

@ -54,6 +54,28 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
} else if ok {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already owns active agency")
}
// H5 在首个响应丢失、弹窗重新打开等情况下可能为同一目标生成新的 command_id。
// 同一 BD 的 pending 邀请属于同一业务动作,记录新命令到既有结果并按成功回放;其他 BD 仍不能覆盖目标已收到的邀请。
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeAgency, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
if pending.InviterBDUserID != inviter.UserID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has pending agency invitation")
}
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
CommandID: command.CommandID,
CommandType: hostdomain.CommandTypeInviteAgency,
ResultType: hostdomain.ResultTypeInvitation,
ResultID: pending.InvitationID,
CreatedAtMs: command.NowMs,
}); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
return pending, nil
}
// BD 发出的 Agency 邀请要记录直接上级 BD 和负责人链路;
// 如果本次以负责人身份邀请,则直接 BD 和负责人都指向 Leader 自己。

View File

@ -602,6 +602,23 @@ func (r *Repository) ListRoleInvitations(ctx context.Context, command hostservic
return queryRoleInvitations(ctx, r.db, query, args...)
}
// queryPendingRoleInvitationByTarget 通过 pending_target_key 唯一索引读取目标当前邀请。
// 邀请创建事务传入 forUpdate=true把“检查 pending + 创建/回放邀请”串行化,避免连续点击用不同 command_id 撞成泛化 409。
func queryPendingRoleInvitationByTarget(ctx context.Context, q sqlQueryer, invitationType string, targetUserID int64, forUpdate bool) (hostdomain.RoleInvitation, bool, error) {
query := fmt.Sprintf(`SELECT %s FROM role_invitations WHERE app_code = ? AND pending_target_key = ?`, roleInvitationColumns)
if forUpdate {
query += " FOR UPDATE"
}
invitation, err := scanRoleInvitation(q.QueryRowContext(ctx, query, appcode.FromContext(ctx), fmt.Sprintf("%s:%d", invitationType, targetUserID)))
if err == sql.ErrNoRows {
return hostdomain.RoleInvitation{}, false, nil
}
if err != nil {
return hostdomain.RoleInvitation{}, false, err
}
return invitation, true, nil
}
// ListAgencyMembers 读取 Agency 成员关系列表。
func (r *Repository) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) {
query := fmt.Sprintf(`SELECT %s FROM agency_memberships WHERE app_code = ? AND agency_id = ?`, agencyMembershipColumns)