256 lines
14 KiB
Go
256 lines
14 KiB
Go
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 {
|
||
return hostdomain.HostProfile{}, false, xerr.New(xerr.Conflict, "host profile is not active")
|
||
}
|
||
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 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, region_id, 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, profile.RegionID, 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 region_id = ?, current_agency_id = ?, current_membership_id = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, profile.RegionID, 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, region_id, parent_bd_user_id, name, status,
|
||
join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appcode.FromContext(ctx), agency.AgencyID, agency.OwnerUserID, agency.RegionID, agency.ParentBDUserID, agency.Name, 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 insertBDProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
|
||
// BD 身份行和 host_profile 相互独立;BD 用户不会自动成为 Host。
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO bd_profiles (
|
||
app_code, user_id, role, region_id, parent_leader_user_id, status,
|
||
created_by_user_id, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appcode.FromContext(ctx), profile.UserID, profile.Role, profile.RegionID, nullableRegionID(profile.ParentLeaderUserID), 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 = ?
|
||
`, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
|
||
return err
|
||
}
|
||
|
||
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, region_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.RegionID, 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 和区域快照,审核时基于这份快照再校验当前事实。
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO agency_applications (
|
||
app_code, application_id, command_id, applicant_user_id, agency_id, region_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.RegionID, 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, region_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.RegionID, 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
|
||
}
|