2026-06-30 13:05:13 +08:00

418 lines
21 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 host
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
hostdomain "hyapp/services/user-service/internal/domain/host"
)
// commandResult 读取写命令流水。
// 命中表示前一次业务事务已经提交,调用方必须按持久化结果重建返回值。
func commandResult(ctx context.Context, q sqlQueryer, commandID string) (hostdomain.CommandResult, bool, error) {
var result hostdomain.CommandResult
err := q.QueryRowContext(ctx, `
SELECT command_id, command_type, result_type, result_id, created_at_ms
FROM host_command_results
WHERE app_code = ? AND command_id = ?
`, appcode.FromContext(ctx), commandID).Scan(&result.CommandID, &result.CommandType, &result.ResultType, &result.ResultID, &result.CreatedAtMs)
if err == sql.ErrNoRows {
return hostdomain.CommandResult{}, false, nil
}
if err != nil {
return hostdomain.CommandResult{}, false, err
}
return result, true, nil
}
// insertCommandResult 必须放在每个状态机成功之后写入。
// 它必须和领域事实共用同一事务,避免重试看到半提交命令。
func insertCommandResult(ctx context.Context, tx *sql.Tx, result hostdomain.CommandResult) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO host_command_results (app_code, command_id, command_type, result_type, result_id, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), result.CommandID, result.CommandType, result.ResultType, result.ResultID, result.CreatedAtMs)
return err
}
// insertHostOutbox 在产生事实的同一事务里记录可靠集成事件。
// 消费者可以延迟或重建;真正的事实来源仍是和本行一起提交的领域表。
func insertHostOutbox(ctx context.Context, tx *sql.Tx, eventID int64, eventType string, aggregateType string, aggregateID int64, nowMs int64) error {
payload, err := json.Marshal(map[string]any{
"event_type": eventType,
"app_code": appcode.FromContext(ctx),
"aggregate_type": aggregateType,
"aggregate_id": aggregateID,
})
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO host_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms)
VALUES (?, ?, ?, ?, ?, 'pending', CAST(? AS JSON), ?)
`, appcode.FromContext(ctx), eventID, eventType, aggregateType, fmt.Sprintf("%d", aggregateID), string(payload), nowMs)
return err
}
func ensureHostProfileForMembership(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, membershipID int64, agencyID int64, source string, nowMs int64) (hostdomain.HostProfile, bool, error) {
// host_profile 对每个用户只有一行,并且会跨 Agency 成员关系变化长期保留。
// FOR UPDATE 保证“创建身份或挂载当前成员关系”对同一用户是原子的。
profile, err := scanHostProfile(tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM host_profiles WHERE app_code = ? AND user_id = ? FOR UPDATE`, hostProfileColumns), appcode.FromContext(ctx), userID))
if err == sql.ErrNoRows {
// 第一次通过申请时创建长期 Host 身份,并记录 first_became_host_at_ms。
profile = hostdomain.HostProfile{
UserID: userID,
Status: hostdomain.HostStatusActive,
RegionID: regionID,
CurrentAgencyID: agencyID,
CurrentMembershipID: membershipID,
Source: source,
FirstBecameHostAtMs: nowMs,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
return profile, true, insertHostProfile(ctx, tx, profile)
}
if err != nil {
return hostdomain.HostProfile{}, false, err
}
if profile.Status != hostdomain.HostStatusActive {
// disabled 不再算有效 Host 权限;当用户通过邀请或后台开通重新获得 Host 身份时,
// 复用原主键行并挂到本次新 membership避免因为历史停用记录导致身份补齐失败。
profile.Status = hostdomain.HostStatusActive
profile.RegionID = regionID
profile.CurrentAgencyID = agencyID
profile.CurrentMembershipID = membershipID
profile.UpdatedAtMs = nowMs
return profile, true, updateHostProfileForMembership(ctx, tx, profile)
}
if profile.CurrentMembershipID > 0 {
// current_membership 是为“我的 Agency”快速读取做的冗余非 0 表示已经挂载。
return hostdomain.HostProfile{}, false, xerr.New(xerr.Conflict, "host already has active agency membership")
}
// 复用已有身份行时不能重置最早成为主播的时间和来源。
profile.RegionID = regionID
profile.CurrentAgencyID = agencyID
profile.CurrentMembershipID = membershipID
profile.UpdatedAtMs = nowMs
return profile, false, updateHostCurrentMembership(ctx, tx, profile)
}
func ensureHostProfileForExistingMembership(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, membership hostdomain.AgencyMembership, source string, nowMs int64) (hostdomain.HostProfile, bool, error) {
profile, ok, err := queryHostProfileByUserMaybe(ctx, tx, userID, true)
if err != nil {
return hostdomain.HostProfile{}, false, err
}
if !ok {
// 极端历史数据可能只有 active membership 没有 host_profile这里补回 Host 身份,
// 但指针仍指向已有 membership绝不把用户重新绑定到新创建的 Agency。
profile = hostdomain.HostProfile{
UserID: userID,
Status: hostdomain.HostStatusActive,
RegionID: regionID,
CurrentAgencyID: membership.AgencyID,
CurrentMembershipID: membership.MembershipID,
Source: source,
FirstBecameHostAtMs: nowMs,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
return profile, true, insertHostProfile(ctx, tx, profile)
}
changed := profile.Status != hostdomain.HostStatusActive || profile.CurrentAgencyID != membership.AgencyID || profile.CurrentMembershipID != membership.MembershipID
profile.Status = hostdomain.HostStatusActive
profile.RegionID = regionID
profile.CurrentAgencyID = membership.AgencyID
profile.CurrentMembershipID = membership.MembershipID
profile.UpdatedAtMs = nowMs
if !changed {
return profile, false, nil
}
return profile, true, updateHostProfileForMembership(ctx, tx, profile)
}
func insertHostProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
// 这里只用于首次创建身份;成员关系变化走 updateHostCurrentMembership。
_, err := tx.ExecContext(ctx, `
INSERT INTO host_profiles (
app_code, user_id, status, current_agency_id, current_membership_id, source,
first_became_host_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Status, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.Source, profile.FirstBecameHostAtMs, profile.CreatedAtMs, profile.UpdatedAtMs)
return err
}
func updateHostCurrentMembership(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
// 这里只更新可变的“当前成员关系指针”字段。
// 状态、来源、首次成为主播时间属于身份历史,不在这里重写。
_, err := tx.ExecContext(ctx, `
UPDATE host_profiles
SET current_agency_id = ?, current_membership_id = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func updateHostProfileForMembership(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
// 恢复 disabled Host 或修复历史指针时,需要同时写 status 和当前归属;
// source/first_became_host_at_ms 仍然保留首次成为 Host 的历史语义。
_, err := tx.ExecContext(ctx, `
UPDATE host_profiles
SET status = ?, current_agency_id = ?, current_membership_id = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, profile.Status, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func updateUserRegion(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, nowMs int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE users
SET region_id = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, nullableRegionID(regionID), nowMs, appcode.FromContext(ctx), userID)
return err
}
func insertAgency(ctx context.Context, tx *sql.Tx, agency hostdomain.Agency) error {
// 第一阶段只有接受 Agency 邀请时会创建 Agency。
_, err := tx.ExecContext(ctx, `
INSERT INTO agencies (
app_code, agency_id, owner_user_id, parent_bd_user_id, name, avatar, status,
join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), agency.AgencyID, agency.OwnerUserID, agency.ParentBDUserID, agency.Name, agency.Avatar, agency.Status, agency.JoinEnabled, agency.MaxHosts, agency.CreatedByUserID, agency.CreatedAtMs, agency.UpdatedAtMs)
return err
}
func updateAgencyAdminState(ctx context.Context, tx *sql.Tx, agency hostdomain.Agency) error {
// 后台 Phase 2 只允许调整 Agency 状态和入会开关owner/parent_bd/region 不在这里迁移。
_, err := tx.ExecContext(ctx, `
UPDATE agencies
SET status = ?, join_enabled = ?, updated_at_ms = ?
WHERE app_code = ? AND agency_id = ?
`, agency.Status, agency.JoinEnabled, agency.UpdatedAtMs, appcode.FromContext(ctx), agency.AgencyID)
return err
}
func updateAgencyProfile(ctx context.Context, tx *sql.Tx, agency hostdomain.Agency) error {
// Agency 资料是独立组织资料;这里绝不更新 users 表,也不从 owner 用户资料重新派生。
_, err := tx.ExecContext(ctx, `
UPDATE agencies
SET name = ?, avatar = ?, updated_at_ms = ?
WHERE app_code = ? AND agency_id = ?
`, agency.Name, agency.Avatar, agency.UpdatedAtMs, appcode.FromContext(ctx), agency.AgencyID)
return err
}
func insertBDProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// 普通 BD 身份行和 host_profile 相互独立BD 用户不会自动成为 Host也不会隐式拥有 BD Leader 权限。
_, err := tx.ExecContext(ctx, `
INSERT INTO bd_profiles (
app_code, user_id, role, parent_leader_user_id, status,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Role, nullableRegionID(profile.ParentLeaderUserID), profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
return err
}
func insertBDLeaderProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// BD Leader 使用独立表承载负责人身份,避免和普通 BD 工资、状态、团队归属混成同一条事实。
_, err := tx.ExecContext(ctx, `
INSERT INTO bd_leader_profiles (
app_code, user_id, status,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
return err
}
func updateBDProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// 状态变更只更新 status/updated_at_ms不改角色、区域和上级 Leader 这些身份历史字段。
_, err := tx.ExecContext(ctx, `
UPDATE bd_profiles
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND role = 'bd'
`, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func updateBDProfileForActivation(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// disabled 普通 BD 被重新授予权限时才允许重写父级 Leader
// active BD 的 parent 代表既有业务关系,任何补齐逻辑都不能覆盖它。
_, err := tx.ExecContext(ctx, `
UPDATE bd_profiles
SET parent_leader_user_id = ?, status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND role = 'bd'
`, nullableRegionID(profile.ParentLeaderUserID), profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// Leader 状态只影响负责人权限和 ADMIN_SALARY_USD 钱包入口,不影响同用户可能存在的普通 BD 身份。
_, err := tx.ExecContext(ctx, `
UPDATE bd_leader_profiles
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func ensureActiveBDLeaderProfile(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, createdByUserID int64, nowMs int64) (hostdomain.BDProfile, string, error) {
profile, ok, err := queryBDLeaderProfileByUserMaybe(ctx, tx, userID, true)
if err != nil {
return hostdomain.BDProfile{}, "", err
}
if !ok {
// 直开 BD Leader 时,负责人身份先成为 active 事实;普通 BD 钱包/工资身份由后续 helper 补齐。
profile = hostdomain.BDProfile{
UserID: userID,
Role: hostdomain.BDRoleLeader,
RegionID: regionID,
Status: hostdomain.BDStatusActive,
CreatedByUserID: createdByUserID,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
if err := insertBDLeaderProfile(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, "", mapHostDuplicateError(err)
}
return profile, "BDCreated", nil
}
if profile.Status == hostdomain.BDStatusActive {
// 已经是 active Leader 时只回放当前事实,不重写 created_by也不重复发状态事件。
return profile, "", nil
}
profile.Status = hostdomain.BDStatusActive
profile.UpdatedAtMs = nowMs
if err := updateBDLeaderProfileStatus(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, "", err
}
return profile, "BDStatusChanged", nil
}
func ensureSelfBDProfileForLeader(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, createdByUserID int64, nowMs int64) (hostdomain.BDProfile, string, error) {
profile, ok, err := queryBDProfileByUserMaybe(ctx, tx, userID, true)
if err != nil {
return hostdomain.BDProfile{}, "", err
}
if !ok {
// BD Leader 需要普通 BD 身份承载下级 Agency/工资等 BD 体系能力;
// 首次补齐时把 parent_leader_user_id 指向自己,形成自有 BD Leader 体系。
profile = hostdomain.BDProfile{
UserID: userID,
Role: hostdomain.BDRoleBD,
RegionID: regionID,
ParentLeaderUserID: userID,
Status: hostdomain.BDStatusActive,
CreatedByUserID: createdByUserID,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
if err := insertBDProfile(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, "", mapHostDuplicateError(err)
}
return profile, "BDCreated", nil
}
if profile.Status == hostdomain.BDStatusActive {
// 已有 active 普通 BD 说明用户已经在某个 BD 关系里;开通 Leader 不能覆盖原 parent。
return profile, "", nil
}
profile.ParentLeaderUserID = userID
profile.Status = hostdomain.BDStatusActive
profile.UpdatedAtMs = nowMs
if err := updateBDProfileForActivation(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, "", err
}
return profile, "BDStatusChanged", nil
}
func insertCoinSellerProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.CoinSellerProfile) error {
// 币商身份是独立角色事实;账户余额属于 wallet-service不在此表冗余。
_, err := tx.ExecContext(ctx, `
INSERT INTO coin_seller_profiles (
app_code, user_id, status, merchant_asset_type,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.MerchantAssetType, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
return err
}
func updateCoinSellerProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.CoinSellerProfile) error {
// 启停币商身份只改状态merchant_asset_type 是账户边界,创建后不能静默迁移。
_, err := tx.ExecContext(ctx, `
UPDATE coin_seller_profiles
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func insertAgencyMembership(ctx context.Context, tx *sql.Tx, membership hostdomain.AgencyMembership) error {
// 成员关系是 Agency 关系历史行;有效且当前的关系也会冗余到身份表。
_, err := tx.ExecContext(ctx, `
INSERT INTO agency_memberships (
app_code, membership_id, agency_id, host_user_id, membership_type, status,
joined_at_ms, ended_at_ms, ended_by_user_id, ended_reason, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), membership.MembershipID, membership.AgencyID, membership.HostUserID, membership.MembershipType, membership.Status, membership.JoinedAtMs, nullableRegionID(membership.EndedAtMs), nullableRegionID(membership.EndedByUserID), membership.EndedReason, membership.CreatedAtMs, membership.UpdatedAtMs)
return err
}
func endAgencyMembership(ctx context.Context, tx *sql.Tx, membership hostdomain.AgencyMembership) error {
// 结束成员关系只改状态,不删除历史行,便于审计和列表回看。
_, err := tx.ExecContext(ctx, `
UPDATE agency_memberships
SET status = ?, ended_at_ms = ?, ended_by_user_id = ?, ended_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND membership_id = ?
`, membership.Status, nullableRegionID(membership.EndedAtMs), nullableRegionID(membership.EndedByUserID), membership.EndedReason, membership.UpdatedAtMs, appcode.FromContext(ctx), membership.MembershipID)
return err
}
func insertAgencyApplication(ctx context.Context, tx *sql.Tx, application hostdomain.AgencyApplication) error {
// 申请记录只保留申请人和 Agency区域每次读取时都从申请人 users 行投影。
_, err := tx.ExecContext(ctx, `
INSERT INTO agency_applications (
app_code, application_id, command_id, applicant_user_id, agency_id, status,
reviewed_by_user_id, review_reason, reviewed_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), application.ApplicationID, application.CommandID, application.ApplicantUserID, application.AgencyID, application.Status, nullableRegionID(application.ReviewedByUserID), application.ReviewReason, nullableRegionID(application.ReviewedAtMs), application.CreatedAtMs, application.UpdatedAtMs)
return err
}
func updateAgencyApplicationReview(ctx context.Context, tx *sql.Tx, application hostdomain.AgencyApplication) error {
// 审核更新是申请终态的唯一写入口。
_, err := tx.ExecContext(ctx, `
UPDATE agency_applications
SET status = ?, reviewed_by_user_id = ?, review_reason = ?, reviewed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND application_id = ?
`, application.Status, nullableRegionID(application.ReviewedByUserID), application.ReviewReason, nullableRegionID(application.ReviewedAtMs), application.UpdatedAtMs, appcode.FromContext(ctx), application.ApplicationID)
return err
}
func insertRoleInvitation(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation) error {
// 邀请记录保留足够的上级 BD 信息,接受时不需要重新推导组织链路。
_, err := tx.ExecContext(ctx, `
INSERT INTO role_invitations (
app_code, invitation_id, command_id, invitation_type, status, inviter_user_id,
inviter_bd_user_id, target_user_id, agency_name, parent_bd_user_id,
parent_leader_user_id, processed_by_user_id, process_reason, processed_at_ms,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), invitation.InvitationID, invitation.CommandID, invitation.InvitationType, invitation.Status, invitation.InviterUserID, invitation.InviterBDUserID, invitation.TargetUserID, invitation.AgencyName, invitation.ParentBDUserID, nullableRegionID(invitation.ParentLeaderUserID), nullableRegionID(invitation.ProcessedByUserID), invitation.ProcessReason, nullableRegionID(invitation.ProcessedAtMs), invitation.CreatedAtMs, invitation.UpdatedAtMs)
return err
}
func updateRoleInvitationProcessed(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation) error {
// 处理邀请时统一记录处理人、原因和时间,覆盖接受、拒绝和取消。
_, err := tx.ExecContext(ctx, `
UPDATE role_invitations
SET status = ?, processed_by_user_id = ?, process_reason = ?, processed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND invitation_id = ?
`, invitation.Status, nullableRegionID(invitation.ProcessedByUserID), invitation.ProcessReason, nullableRegionID(invitation.ProcessedAtMs), invitation.UpdatedAtMs, appcode.FromContext(ctx), invitation.InvitationID)
return err
}