2026-06-14 23:42:39 +08:00

558 lines
18 KiB
Go
Raw Permalink 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 hostorg
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
salaryWalletRoleHost = "host"
salaryWalletRoleAgency = "agency"
salaryWalletRoleBD = "bd"
salaryWalletRoleBDLeader = "bd_leader"
assetHostSalaryUSD = "HOST_SALARY_USD"
assetAgencySalaryUSD = "AGENCY_SALARY_USD"
assetBDSalaryUSD = "BD_SALARY_USD"
assetAdminSalaryUSD = "ADMIN_SALARY_USD"
salaryWalletAdjustIncrease = "increase"
salaryWalletAdjustDecrease = "decrease"
)
type salaryWalletHistoryQuery struct {
Page int
PageSize int
Role string
UserID int64
}
type salaryWalletAdjustRequest struct {
CommandID string `json:"commandId"`
Role string `json:"role" binding:"required"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
Type string `json:"type" binding:"required"`
AmountMinor int64 `json:"amountMinor" binding:"required"`
Reason string `json:"reason" binding:"required"`
}
type salaryWalletOperatorDTO struct {
AdminID string `json:"adminId"`
Username string `json:"username"`
Name string `json:"name"`
}
type salaryWalletHistoryItem struct {
EntryID int64 `json:"entryId"`
TransactionID string `json:"transactionId"`
CommandID string `json:"commandId"`
BizType string `json:"bizType"`
ExternalRef string `json:"externalRef"`
Role string `json:"role"`
UserID string `json:"userId"`
AssetType string `json:"assetType"`
Direction string `json:"direction"`
AmountMinor int64 `json:"amountMinor"`
AvailableDelta int64 `json:"availableDelta"`
AvailableAfter int64 `json:"availableAfter"`
FrozenDelta int64 `json:"frozenDelta"`
FrozenAfter int64 `json:"frozenAfter"`
CounterpartyUserID string `json:"counterpartyUserId"`
RoomID string `json:"roomId"`
Reason string `json:"reason"`
OperatorUserID string `json:"operatorUserId"`
Operator salaryWalletOperatorDTO `json:"operator"`
Metadata map[string]any `json:"metadata"`
CreatedAtMS int64 `json:"createdAtMs"`
}
type salaryWalletAdjustResult struct {
TransactionID string `json:"transactionId"`
Role string `json:"role"`
UserID string `json:"userId"`
AssetType string `json:"assetType"`
AmountMinor int64 `json:"amountMinor"`
Reason string `json:"reason"`
Balance userclient.SalaryWallet `json:"balance"`
}
func (r *Reader) fillBDProfileSalaryWallets(ctx context.Context, role string, items []*userclient.BDProfile) error {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.UserID > 0 {
userIDs = append(userIDs, item.UserID)
}
}
wallets, err := r.salaryWalletBalances(ctx, appctx.FromContext(ctx), role, userIDs)
if err != nil {
return err
}
for _, item := range items {
item.SalaryWallet = wallets[item.UserID]
}
return nil
}
func (r *Reader) fillAgencySalaryWallets(ctx context.Context, items []*userclient.Agency) error {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.OwnerUserID > 0 {
userIDs = append(userIDs, item.OwnerUserID)
}
}
wallets, err := r.salaryWalletBalances(ctx, appctx.FromContext(ctx), salaryWalletRoleAgency, userIDs)
if err != nil {
return err
}
for _, item := range items {
item.SalaryWallet = wallets[item.OwnerUserID]
}
return nil
}
func (r *Reader) fillHostSalaryWallets(ctx context.Context, items []*userclient.HostProfile) error {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.UserID > 0 {
userIDs = append(userIDs, item.UserID)
}
}
wallets, err := r.salaryWalletBalances(ctx, appctx.FromContext(ctx), salaryWalletRoleHost, userIDs)
if err != nil {
return err
}
for _, item := range items {
item.SalaryWallet = wallets[item.UserID]
}
return nil
}
func (r *Reader) salaryWalletBalances(ctx context.Context, appCode string, role string, userIDs []int64) (map[int64]userclient.SalaryWallet, error) {
role = normalizeSalaryWalletRole(role)
assetType := salaryWalletAssetType(role)
result := make(map[int64]userclient.SalaryWallet, len(userIDs))
for _, userID := range positiveUniqueIDs(userIDs) {
// 列表必须稳定返回该身份对应的钱包对象;没有 wallet_accounts 行时余额就是 0而不是让前端猜资产类型。
result[userID] = userclient.SalaryWallet{Role: role, UserID: userID, AssetType: assetType}
}
if r == nil || r.walletDB == nil || len(result) == 0 {
return result, nil
}
args := []any{appCode, assetType}
for userID := range result {
args = append(args, userID)
}
rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
SELECT user_id, available_amount, frozen_amount, updated_at_ms
FROM wallet_accounts
WHERE app_code = ? AND asset_type = ? AND user_id IN (%s)
`, sqlPlaceholders(len(result))), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var userID int64
wallet := userclient.SalaryWallet{Role: role, AssetType: assetType}
if err := rows.Scan(&userID, &wallet.AvailableAmount, &wallet.FrozenAmount, &wallet.UpdatedAtMs); err != nil {
return nil, err
}
wallet.UserID = userID
result[userID] = wallet
}
return result, rows.Err()
}
func (s *Service) ListSalaryWalletHistory(ctx context.Context, query salaryWalletHistoryQuery) ([]salaryWalletHistoryItem, int64, error) {
query = normalizeSalaryWalletHistoryQuery(query)
if s == nil || s.reader == nil || s.reader.walletDB == nil {
return nil, 0, fmt.Errorf("wallet mysql is not configured")
}
if query.Role == "" || query.UserID <= 0 {
return nil, 0, fmt.Errorf("role and user_id are required")
}
// 历史查询先验证用户确实拥有对应组织身份,避免拿 host 身份去看 agency/BD 的工资资产流水。
if err := s.reader.requireSalaryWalletIdentity(ctx, query.Role, query.UserID); err != nil {
return nil, 0, err
}
return s.reader.listSalaryWalletHistory(ctx, query)
}
func (r *Reader) listSalaryWalletHistory(ctx context.Context, query salaryWalletHistoryQuery) ([]salaryWalletHistoryItem, int64, error) {
appCode := appctx.FromContext(ctx)
assetType := salaryWalletAssetType(query.Role)
args := []any{appCode, query.UserID, assetType}
whereSQL := `WHERE e.app_code = ? AND e.user_id = ? AND e.asset_type = ?`
var total int64
if err := r.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL, args...,
).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.walletDB.QueryContext(ctx, `
SELECT e.entry_id, e.transaction_id, wt.command_id, wt.biz_type, wt.external_ref,
e.available_delta, e.available_after, e.frozen_delta, e.frozen_after,
e.counterparty_user_id, e.room_id, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL+`
ORDER BY e.created_at_ms DESC, e.entry_id DESC
LIMIT ? OFFSET ?`, append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]salaryWalletHistoryItem, 0, query.PageSize)
operatorIDs := make([]int64, 0, query.PageSize)
for rows.Next() {
var item salaryWalletHistoryItem
var counterpartyUserID int64
var metadataJSON string
if err := rows.Scan(
&item.EntryID,
&item.TransactionID,
&item.CommandID,
&item.BizType,
&item.ExternalRef,
&item.AvailableDelta,
&item.AvailableAfter,
&item.FrozenDelta,
&item.FrozenAfter,
&counterpartyUserID,
&item.RoomID,
&metadataJSON,
&item.CreatedAtMS,
); err != nil {
return nil, 0, err
}
metadata, err := parseSalaryWalletMetadata(metadataJSON)
if err != nil {
return nil, 0, err
}
operatorID := salaryWalletMetadataInt64(metadata, "operator_user_id", "operatorUserId")
if operatorID > 0 {
operatorIDs = append(operatorIDs, operatorID)
}
item.Role = query.Role
item.UserID = strconv.FormatInt(query.UserID, 10)
item.AssetType = assetType
item.Direction = salaryWalletDirection(item.AvailableDelta)
item.AmountMinor = item.AvailableDelta
item.CounterpartyUserID = formatSalaryWalletID(counterpartyUserID)
item.Reason = salaryWalletMetadataString(metadata, "reason")
item.OperatorUserID = formatSalaryWalletID(operatorID)
item.Metadata = metadata
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
operators, err := r.salaryWalletAdminProfiles(ctx, operatorIDs)
if err != nil {
return nil, 0, err
}
for i := range items {
operatorID, _ := strconv.ParseInt(items[i].OperatorUserID, 10, 64)
if operator, ok := operators[operatorID]; ok {
items[i].Operator = operator
}
}
return items, total, nil
}
func (s *Service) AdjustSalaryWallet(ctx context.Context, actorID int64, requestID string, req salaryWalletAdjustRequest) (salaryWalletAdjustResult, error) {
if s == nil || s.walletClient == nil || s.reader == nil {
return salaryWalletAdjustResult{}, fmt.Errorf("wallet client is not configured")
}
role := normalizeSalaryWalletRole(req.Role)
targetUserID := req.TargetUserID.Int64()
if role == "" || targetUserID <= 0 || req.AmountMinor <= 0 {
return salaryWalletAdjustResult{}, fmt.Errorf("role, target_user_id and amount are required")
}
if actorID <= 0 {
return salaryWalletAdjustResult{}, fmt.Errorf("operator_user_id is required")
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
return salaryWalletAdjustResult{}, fmt.Errorf("reason is required")
}
if len(reason) > 512 {
return salaryWalletAdjustResult{}, fmt.Errorf("reason is too long")
}
// 调整工资钱包前必须确认目标用户具备该身份;同一个 user_id 可能同时有多种身份,资产类型不能由前端随意指定。
if err := s.reader.requireSalaryWalletIdentity(ctx, role, targetUserID); err != nil {
return salaryWalletAdjustResult{}, err
}
amount := req.AmountMinor
adjustType := strings.ToLower(strings.TrimSpace(req.Type))
switch adjustType {
case salaryWalletAdjustIncrease:
case salaryWalletAdjustDecrease:
// wallet-service 的 AdminCreditAsset 以带符号金额入账,扣减也走同一条幂等账务链路并由钱包层做余额不透支校验。
amount = -amount
default:
return salaryWalletAdjustResult{}, fmt.Errorf("salary adjustment type is invalid")
}
commandID := strings.TrimSpace(req.CommandID)
if commandID == "" {
commandID = defaultSalaryWalletCommandID(requestID)
}
if len(commandID) > 128 {
return salaryWalletAdjustResult{}, fmt.Errorf("command_id is too long")
}
assetType := salaryWalletAssetType(role)
// 手工增减工资只提交目标身份对应的工资资产;交易、分录、余额和幂等命令统一由 wallet-service 落库。
resp, err := s.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
CommandId: commandID,
TargetUserId: targetUserID,
AssetType: assetType,
Amount: amount,
OperatorUserId: actorID,
Reason: reason,
EvidenceRef: defaultSalaryWalletEvidenceRef(requestID),
AppCode: appctx.FromContext(ctx),
})
if err != nil {
return salaryWalletAdjustResult{}, err
}
wallet := userclient.SalaryWallet{
Role: role,
UserID: targetUserID,
AssetType: assetType,
AvailableAmount: 0,
FrozenAmount: 0,
UpdatedAtMs: time.Now().UnixMilli(),
}
if balance := resp.GetBalance(); balance != nil {
wallet.AvailableAmount = balance.GetAvailableAmount()
wallet.FrozenAmount = balance.GetFrozenAmount()
}
return salaryWalletAdjustResult{
TransactionID: resp.GetTransactionId(),
Role: role,
UserID: strconv.FormatInt(targetUserID, 10),
AssetType: assetType,
AmountMinor: amount,
Reason: reason,
Balance: wallet,
}, nil
}
func (r *Reader) requireSalaryWalletIdentity(ctx context.Context, role string, userID int64) error {
if r == nil || r.db == nil {
return errUserDBNotConfigured()
}
if userID <= 0 {
return fmt.Errorf("user_id is required")
}
appCode := appctx.FromContext(ctx)
var query string
args := []any{appCode, userID}
switch normalizeSalaryWalletRole(role) {
case salaryWalletRoleHost:
query = `SELECT COUNT(1) FROM host_profiles WHERE app_code = ? AND user_id = ?`
case salaryWalletRoleAgency:
query = `SELECT COUNT(1) FROM agencies WHERE app_code = ? AND owner_user_id = ? AND status <> 'deleted'`
case salaryWalletRoleBD:
query = `SELECT COUNT(1) FROM bd_profiles WHERE app_code = ? AND user_id = ? AND role = 'bd'`
case salaryWalletRoleBDLeader:
query = `SELECT COUNT(1) FROM bd_leader_profiles WHERE app_code = ? AND user_id = ?`
default:
return fmt.Errorf("salary wallet role is invalid")
}
var count int
if err := r.db.QueryRowContext(ctx, query, args...).Scan(&count); err != nil {
return err
}
if count == 0 {
return fmt.Errorf("salary wallet identity not found")
}
return nil
}
func (r *Reader) salaryWalletAdminProfiles(ctx context.Context, adminIDs []int64) (map[int64]salaryWalletOperatorDTO, error) {
result := make(map[int64]salaryWalletOperatorDTO, len(adminIDs))
if r == nil || r.adminDB == nil {
return result, nil
}
adminIDs = positiveUniqueIDs(adminIDs)
if len(adminIDs) == 0 {
return result, nil
}
args := make([]any, 0, len(adminIDs))
for _, id := range adminIDs {
args = append(args, id)
}
rows, err := r.adminDB.QueryContext(ctx, fmt.Sprintf(`
SELECT id, COALESCE(username, ''), COALESCE(name, '')
FROM admin_users
WHERE id IN (%s)
`, sqlPlaceholders(len(adminIDs))), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id int64
var item salaryWalletOperatorDTO
if err := rows.Scan(&id, &item.Username, &item.Name); err != nil {
return nil, err
}
item.AdminID = strconv.FormatInt(id, 10)
result[id] = item
}
return result, rows.Err()
}
func normalizeSalaryWalletHistoryQuery(query salaryWalletHistoryQuery) salaryWalletHistoryQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.Role = normalizeSalaryWalletRole(query.Role)
return query
}
func normalizeSalaryWalletRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case salaryWalletRoleHost:
return salaryWalletRoleHost
case salaryWalletRoleAgency:
return salaryWalletRoleAgency
case salaryWalletRoleBD:
return salaryWalletRoleBD
case salaryWalletRoleBDLeader, "leader", "bdleader", "admin":
return salaryWalletRoleBDLeader
default:
return ""
}
}
func salaryWalletAssetType(role string) string {
switch normalizeSalaryWalletRole(role) {
case salaryWalletRoleHost:
return assetHostSalaryUSD
case salaryWalletRoleAgency:
return assetAgencySalaryUSD
case salaryWalletRoleBD:
return assetBDSalaryUSD
case salaryWalletRoleBDLeader:
// BD Leader 在工资政策里沿用 admin 结算身份,所以列表和历史都读取 ADMIN_SALARY_USD。
return assetAdminSalaryUSD
default:
return ""
}
}
func salaryWalletDirection(delta int64) string {
if delta < 0 {
return "expense"
}
return "income"
}
func parseSalaryWalletMetadata(value string) (map[string]any, error) {
value = strings.TrimSpace(value)
if value == "" || value == "null" {
return map[string]any{}, nil
}
var metadata map[string]any
if err := json.Unmarshal([]byte(value), &metadata); err != nil {
return nil, err
}
if metadata == nil {
return map[string]any{}, nil
}
return metadata, nil
}
func salaryWalletMetadataString(metadata map[string]any, key string) string {
value, ok := metadata[key]
if !ok || value == nil {
return ""
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text)
}
return strings.TrimSpace(fmt.Sprint(value))
}
func salaryWalletMetadataInt64(metadata map[string]any, keys ...string) int64 {
for _, key := range keys {
value, ok := metadata[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed
}
}
}
return 0
}
func formatSalaryWalletID(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func positiveUniqueIDs(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func defaultSalaryWalletCommandID(requestID string) string {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return "salary-wallet-adjustment-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
}
return "salary-wallet-adjustment-" + requestID
}
func defaultSalaryWalletEvidenceRef(requestID string) string {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return "salary-wallet-adjustment"
}
return "admin-request:" + requestID
}