604 lines
18 KiB
Go
604 lines
18 KiB
Go
package hostwithdrawal
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
)
|
||
|
||
type Service struct {
|
||
adminDB *sql.DB
|
||
walletDB *sql.DB
|
||
userDB *sql.DB
|
||
}
|
||
|
||
type userProfile struct {
|
||
UserID int64
|
||
DisplayUserID string
|
||
Username string
|
||
Avatar string
|
||
}
|
||
|
||
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service {
|
||
return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB}
|
||
}
|
||
|
||
func (s *Service) List(ctx context.Context, query listQuery) ([]itemDTO, int64, error) {
|
||
var err error
|
||
query, err = normalizeListQuery(query)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if s == nil || s.adminDB == nil || s.walletDB == nil {
|
||
return nil, 0, fmt.Errorf("host withdrawal storage is not configured")
|
||
}
|
||
|
||
keywordUserIDs, err := s.resolveKeywordUserIDs(ctx, query.AppCode, query.Keyword)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
fetchLimit := offset(query.Page, query.PageSize) + query.PageSize
|
||
|
||
items := make([]itemDTO, 0, fetchLimit)
|
||
total := int64(0)
|
||
if query.RecordType == "" || query.RecordType == recordTypeSalaryTransfer {
|
||
// 工资转币商只展示已完成账本事实;pending/approved/rejected 属于提现申请状态,不能误筛到钱包转账。
|
||
if query.Status == "" || query.Status == statusCompleted {
|
||
count, err := s.countSalaryTransfers(ctx, query, keywordUserIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
total += count
|
||
records, err := s.listSalaryTransfers(ctx, query, keywordUserIDs, fetchLimit)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, records...)
|
||
}
|
||
}
|
||
if query.RecordType == "" || query.RecordType == recordTypeWithdrawal {
|
||
// 提现申请状态来自后台申请表;completed 只代表钱包转币商完成态,所以提现分支直接跳过。
|
||
if query.Status != statusCompleted {
|
||
count, err := s.countWithdrawalApplications(ctx, query, keywordUserIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
total += count
|
||
records, err := s.listWithdrawalApplications(ctx, query, keywordUserIDs, fetchLimit)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
items = append(items, records...)
|
||
}
|
||
}
|
||
|
||
enriched, err := s.enrichUsers(ctx, query.AppCode, items)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return pageMergedItems(enriched, query.Page, query.PageSize), total, nil
|
||
}
|
||
|
||
func (s *Service) countSalaryTransfers(ctx context.Context, query listQuery, keywordUserIDs []int64) (int64, error) {
|
||
whereSQL, args := salaryTransferWhere(query, keywordUserIDs)
|
||
var total int64
|
||
err := s.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)
|
||
return total, err
|
||
}
|
||
|
||
func (s *Service) listSalaryTransfers(ctx context.Context, query listQuery, keywordUserIDs []int64, limit int) ([]itemDTO, error) {
|
||
if limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
whereSQL, args := salaryTransferWhere(query, keywordUserIDs)
|
||
rows, err := s.walletDB.QueryContext(ctx, `
|
||
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id, e.counterparty_user_id,
|
||
e.available_delta, e.available_after, 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 ?`,
|
||
append(args, limit)...,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]itemDTO, 0, limit)
|
||
for rows.Next() {
|
||
var (
|
||
entryID int64
|
||
transactionID string
|
||
commandID string
|
||
sellerUserID int64
|
||
sourceUserID int64
|
||
availableDelta int64
|
||
sellerBalanceAfter int64
|
||
metadataJSON string
|
||
createdAtMS int64
|
||
)
|
||
if err := rows.Scan(&entryID, &transactionID, &commandID, &sellerUserID, &sourceUserID, &availableDelta, &sellerBalanceAfter, &metadataJSON, &createdAtMS); err != nil {
|
||
return nil, err
|
||
}
|
||
metadata, err := parseMetadataJSON(metadataJSON)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if value := metadataInt64(metadata, "source_user_id", "sourceUserId"); value > 0 {
|
||
sourceUserID = value
|
||
}
|
||
if value := metadataInt64(metadata, "seller_user_id", "sellerUserId"); value > 0 {
|
||
sellerUserID = value
|
||
}
|
||
assetType := firstNonEmpty(metadataString(metadata, "salary_asset_type"), query.SalaryAssetType)
|
||
coinAmount := metadataInt64(metadata, "coin_amount", "coinAmount")
|
||
if coinAmount == 0 {
|
||
coinAmount = absInt64(availableDelta)
|
||
}
|
||
if metadata == nil {
|
||
metadata = map[string]any{}
|
||
}
|
||
metadata["seller_balance_after"] = sellerBalanceAfter
|
||
items = append(items, itemDTO{
|
||
ID: fmt.Sprintf("%s:%d", recordTypeSalaryTransfer, entryID),
|
||
RecordType: recordTypeSalaryTransfer,
|
||
Identity: identityFromAsset(assetType),
|
||
SalaryAssetType: assetType,
|
||
SourceUserID: formatOptionalID(sourceUserID),
|
||
SourceUser: userDTO{UserID: formatOptionalID(sourceUserID)},
|
||
SellerUserID: formatOptionalID(sellerUserID),
|
||
SellerUser: userDTO{UserID: formatOptionalID(sellerUserID)},
|
||
USDMinorAmount: metadataInt64(metadata, "salary_usd_minor", "salaryUsdMinor"),
|
||
CoinAmount: coinAmount,
|
||
Status: statusCompleted,
|
||
TransactionID: transactionID,
|
||
CommandID: commandID,
|
||
Metadata: metadata,
|
||
CreatedAtMS: createdAtMS,
|
||
UpdatedAtMS: createdAtMS,
|
||
})
|
||
}
|
||
return items, rows.Err()
|
||
}
|
||
|
||
func (s *Service) countWithdrawalApplications(ctx context.Context, query listQuery, keywordUserIDs []int64) (int64, error) {
|
||
whereSQL, args := withdrawalWhere(query, keywordUserIDs)
|
||
var total int64
|
||
err := s.adminDB.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM admin_user_withdrawal_applications a
|
||
`+whereSQL,
|
||
args...,
|
||
).Scan(&total)
|
||
return total, err
|
||
}
|
||
|
||
func (s *Service) listWithdrawalApplications(ctx context.Context, query listQuery, keywordUserIDs []int64, limit int) ([]itemDTO, error) {
|
||
if limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
whereSQL, args := withdrawalWhere(query, keywordUserIDs)
|
||
rows, err := s.adminDB.QueryContext(ctx, `
|
||
SELECT id, user_id, salary_asset_type, withdraw_amount_minor, withdraw_method, withdraw_address,
|
||
freeze_command_id, freeze_transaction_id, audit_command_id, audit_transaction_id,
|
||
status, COALESCE(audit_remark, ''), audit_image_url, created_at_ms, updated_at_ms
|
||
FROM admin_user_withdrawal_applications a
|
||
`+whereSQL+`
|
||
ORDER BY a.created_at_ms DESC, a.id DESC
|
||
LIMIT ?`,
|
||
append(args, limit)...,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]itemDTO, 0, limit)
|
||
for rows.Next() {
|
||
var (
|
||
id int64
|
||
userIDText string
|
||
assetType string
|
||
amountMinor int64
|
||
withdrawMethod string
|
||
withdrawAddress string
|
||
freezeCommandID string
|
||
freezeTransactionID string
|
||
auditCommandID string
|
||
auditTransactionID string
|
||
status string
|
||
auditRemark string
|
||
auditImageURL string
|
||
createdAtMS int64
|
||
updatedAtMS int64
|
||
)
|
||
if err := rows.Scan(&id, &userIDText, &assetType, &amountMinor, &withdrawMethod, &withdrawAddress, &freezeCommandID, &freezeTransactionID, &auditCommandID, &auditTransactionID, &status, &auditRemark, &auditImageURL, &createdAtMS, &updatedAtMS); err != nil {
|
||
return nil, err
|
||
}
|
||
sourceUserID := parseInt64OrZero(userIDText)
|
||
items = append(items, itemDTO{
|
||
ID: fmt.Sprintf("%s:%d", recordTypeWithdrawal, id),
|
||
RecordType: recordTypeWithdrawal,
|
||
Identity: identityFromAsset(assetType),
|
||
SalaryAssetType: assetType,
|
||
SourceUserID: formatOptionalID(sourceUserID),
|
||
SourceUser: userDTO{UserID: firstNonEmpty(formatOptionalID(sourceUserID), userIDText)},
|
||
USDMinorAmount: amountMinor,
|
||
Status: status,
|
||
CommandID: firstNonEmpty(auditCommandID, freezeCommandID),
|
||
ApplicationID: strconv.FormatInt(id, 10),
|
||
FreezeTransactionID: freezeTransactionID,
|
||
AuditTransactionID: auditTransactionID,
|
||
WithdrawMethod: withdrawMethod,
|
||
WithdrawAddress: withdrawAddress,
|
||
AuditRemark: auditRemark,
|
||
AuditImageURL: auditImageURL,
|
||
Metadata: map[string]any{},
|
||
CreatedAtMS: createdAtMS,
|
||
UpdatedAtMS: updatedAtMS,
|
||
})
|
||
}
|
||
return items, rows.Err()
|
||
}
|
||
|
||
func salaryTransferWhere(query listQuery, keywordUserIDs []int64) (string, []any) {
|
||
where := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND wt.status = ?"
|
||
args := []any{query.AppCode, assetCoinSellerCoin, bizSalaryTransfer, transactionSucceeded}
|
||
if query.SalaryAssetType != "" {
|
||
// 身份维度是工资资产类型,来源写在交易 metadata 中;只筛工资转币商事实,不触碰普通币商出货或后台进货。
|
||
where += " AND JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.salary_asset_type')) = ?"
|
||
args = append(args, query.SalaryAssetType)
|
||
}
|
||
if query.StartAtMS > 0 {
|
||
where += " AND e.created_at_ms >= ?"
|
||
args = append(args, query.StartAtMS)
|
||
}
|
||
if query.EndAtMS > 0 {
|
||
where += " AND e.created_at_ms < ?"
|
||
args = append(args, query.EndAtMS)
|
||
}
|
||
if keywordSQL, keywordArgs := salaryTransferKeywordSQL(query.Keyword, keywordUserIDs); keywordSQL != "" {
|
||
where += " AND (" + keywordSQL + ")"
|
||
args = append(args, keywordArgs...)
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func salaryTransferKeywordSQL(keyword string, userIDs []int64) (string, []any) {
|
||
clauses := make([]string, 0, 4)
|
||
args := make([]any, 0)
|
||
if len(userIDs) > 0 {
|
||
clauses = append(clauses, "e.user_id IN ("+placeholders(len(userIDs))+")")
|
||
for _, id := range userIDs {
|
||
args = append(args, id)
|
||
}
|
||
clauses = append(clauses, "e.counterparty_user_id IN ("+placeholders(len(userIDs))+")")
|
||
for _, id := range userIDs {
|
||
args = append(args, id)
|
||
}
|
||
}
|
||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
clauses = append(clauses, "e.transaction_id LIKE ?", "wt.command_id LIKE ?", "CAST(e.user_id AS CHAR) LIKE ?", "CAST(e.counterparty_user_id AS CHAR) LIKE ?")
|
||
args = append(args, like, like, like, like)
|
||
}
|
||
return strings.Join(clauses, " OR "), args
|
||
}
|
||
|
||
func withdrawalWhere(query listQuery, keywordUserIDs []int64) (string, []any) {
|
||
where := "WHERE a.app_code = ?"
|
||
args := []any{query.AppCode}
|
||
if query.SalaryAssetType != "" {
|
||
where += " AND a.salary_asset_type = ?"
|
||
args = append(args, query.SalaryAssetType)
|
||
}
|
||
if query.Status != "" {
|
||
where += " AND a.status = ?"
|
||
args = append(args, query.Status)
|
||
}
|
||
if query.StartAtMS > 0 {
|
||
where += " AND a.created_at_ms >= ?"
|
||
args = append(args, query.StartAtMS)
|
||
}
|
||
if query.EndAtMS > 0 {
|
||
where += " AND a.created_at_ms < ?"
|
||
args = append(args, query.EndAtMS)
|
||
}
|
||
if keywordSQL, keywordArgs := withdrawalKeywordSQL(query.Keyword, keywordUserIDs); keywordSQL != "" {
|
||
where += " AND (" + keywordSQL + ")"
|
||
args = append(args, keywordArgs...)
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func withdrawalKeywordSQL(keyword string, userIDs []int64) (string, []any) {
|
||
clauses := make([]string, 0, 8)
|
||
args := make([]any, 0)
|
||
if len(userIDs) > 0 {
|
||
clauses = append(clauses, "a.user_id IN ("+placeholders(len(userIDs))+")")
|
||
for _, id := range userIDs {
|
||
args = append(args, strconv.FormatInt(id, 10))
|
||
}
|
||
}
|
||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
clauses = append(clauses,
|
||
"CAST(a.id AS CHAR) LIKE ?",
|
||
"a.user_id LIKE ?",
|
||
"a.withdraw_method LIKE ?",
|
||
"a.withdraw_address LIKE ?",
|
||
"a.freeze_transaction_id LIKE ?",
|
||
"a.audit_transaction_id LIKE ?",
|
||
"a.approver_name LIKE ?",
|
||
)
|
||
args = append(args, like, like, like, like, like, like, like)
|
||
}
|
||
return strings.Join(clauses, " OR "), args
|
||
}
|
||
|
||
func (s *Service) resolveKeywordUserIDs(ctx context.Context, appCode string, keyword string) ([]int64, error) {
|
||
keyword = strings.TrimSpace(keyword)
|
||
if keyword == "" {
|
||
return nil, nil
|
||
}
|
||
userIDs := make([]int64, 0, 4)
|
||
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
|
||
// 账务事实可能早于用户资料补全;数字关键字直接加入候选,避免资料缺失吞掉真实流水。
|
||
userIDs = append(userIDs, numeric)
|
||
}
|
||
if s == nil || s.userDB == nil {
|
||
return positiveUniqueInt64s(userIDs), nil
|
||
}
|
||
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, time.Now().UnixMilli())
|
||
args := append([]any{appCode}, matchArgs...)
|
||
rows, err := s.userDB.QueryContext(ctx, `
|
||
SELECT u.user_id
|
||
FROM users u
|
||
WHERE u.app_code = ? AND `+matchSQL+`
|
||
ORDER BY u.updated_at_ms DESC, u.user_id DESC
|
||
LIMIT 1000`,
|
||
args...,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var userID int64
|
||
if err := rows.Scan(&userID); err != nil {
|
||
return nil, err
|
||
}
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
return positiveUniqueInt64s(userIDs), rows.Err()
|
||
}
|
||
|
||
func (s *Service) enrichUsers(ctx context.Context, appCode string, items []itemDTO) ([]itemDTO, error) {
|
||
userIDs := make([]int64, 0, len(items)*2)
|
||
for _, item := range items {
|
||
if id := parseInt64OrZero(item.SourceUserID); id > 0 {
|
||
userIDs = append(userIDs, id)
|
||
}
|
||
if id := parseInt64OrZero(item.SellerUserID); id > 0 {
|
||
userIDs = append(userIDs, id)
|
||
}
|
||
}
|
||
profiles, err := s.userProfiles(ctx, appCode, userIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := append([]itemDTO(nil), items...)
|
||
for i := range out {
|
||
if id := parseInt64OrZero(out[i].SourceUserID); id > 0 {
|
||
if profile, ok := profiles[id]; ok {
|
||
out[i].SourceUser = userDTOFromProfile(profile)
|
||
}
|
||
}
|
||
if id := parseInt64OrZero(out[i].SellerUserID); id > 0 {
|
||
if profile, ok := profiles[id]; ok {
|
||
out[i].SellerUser = userDTOFromProfile(profile)
|
||
}
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
|
||
result := make(map[int64]userProfile, len(userIDs))
|
||
if s == nil || s.userDB == nil || len(userIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
userIDs = positiveUniqueInt64s(userIDs)
|
||
if len(userIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
args := make([]any, 0, len(userIDs)+1)
|
||
args = append(args, appCode)
|
||
for _, id := range userIDs {
|
||
args = append(args, id)
|
||
}
|
||
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||
FROM users
|
||
WHERE app_code = ? AND user_id IN (%s)`,
|
||
placeholders(len(userIDs)),
|
||
), args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var profile userProfile
|
||
if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
|
||
return nil, err
|
||
}
|
||
result[profile.UserID] = profile
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
func userDTOFromProfile(profile userProfile) userDTO {
|
||
return userDTO{
|
||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||
DisplayUserID: profile.DisplayUserID,
|
||
Username: profile.Username,
|
||
Avatar: profile.Avatar,
|
||
}
|
||
}
|
||
|
||
func pageMergedItems(items []itemDTO, page int, pageSize int) []itemDTO {
|
||
sort.SliceStable(items, func(i int, j int) bool {
|
||
if items[i].CreatedAtMS != items[j].CreatedAtMS {
|
||
return items[i].CreatedAtMS > items[j].CreatedAtMS
|
||
}
|
||
if items[i].RecordType != items[j].RecordType {
|
||
return items[i].RecordType < items[j].RecordType
|
||
}
|
||
return recordNumericID(items[i]) > recordNumericID(items[j])
|
||
})
|
||
start := offset(page, pageSize)
|
||
if start >= len(items) {
|
||
return []itemDTO{}
|
||
}
|
||
end := start + pageSize
|
||
if end > len(items) {
|
||
end = len(items)
|
||
}
|
||
return items[start:end]
|
||
}
|
||
|
||
func recordNumericID(item itemDTO) int64 {
|
||
if item.RecordType == recordTypeWithdrawal {
|
||
return parseInt64OrZero(item.ApplicationID)
|
||
}
|
||
parts := strings.Split(item.ID, ":")
|
||
if len(parts) == 2 {
|
||
return parseInt64OrZero(parts[1])
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func parseMetadataJSON(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 metadataString(metadata map[string]any, key string) string {
|
||
value, ok := metadata[key]
|
||
if !ok || value == nil {
|
||
return ""
|
||
}
|
||
if typed, ok := value.(string); ok {
|
||
return strings.TrimSpace(typed)
|
||
}
|
||
return strings.TrimSpace(fmt.Sprint(value))
|
||
}
|
||
|
||
func metadataInt64(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:
|
||
if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if value = strings.TrimSpace(value); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func formatOptionalID(value int64) string {
|
||
if value <= 0 {
|
||
return ""
|
||
}
|
||
return strconv.FormatInt(value, 10)
|
||
}
|
||
|
||
func parseInt64OrZero(value string) int64 {
|
||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||
if err != nil || parsed <= 0 {
|
||
return 0
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
func absInt64(value int64) int64 {
|
||
if value < 0 {
|
||
return -value
|
||
}
|
||
return value
|
||
}
|
||
|
||
func offset(page int, pageSize int) int {
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
return (page - 1) * pageSize
|
||
}
|
||
|
||
func placeholders(count int) string {
|
||
if count <= 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimRight(strings.Repeat("?,", count), ",")
|
||
}
|
||
|
||
func positiveUniqueInt64s(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
|
||
}
|