2026-06-18 22:41:52 +08:00

355 lines
13 KiB
Go

package cprelation
import (
"context"
"database/sql"
"fmt"
"slices"
"strconv"
"strings"
"time"
)
const (
cpListDefaultPageSize = 50
cpListMaxPageSize = 100
dayMS = int64(24 * 60 * 60 * 1000)
)
var (
relationshipStatuses = []string{"active", "ended"}
applicationStatuses = []string{"pending", "accepted", "rejected", "expired", "blocked"}
)
type cpListOptions struct {
Page int
PageSize int
Status string
RelationType string
}
type cpUserDTO struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type cpGiftSnapshotDTO struct {
GiftID string `json:"giftId"`
GiftName string `json:"giftName"`
GiftIconURL string `json:"giftIconUrl"`
GiftAnimationURL string `json:"giftAnimationUrl"`
GiftCount int64 `json:"giftCount"`
GiftValue int64 `json:"giftValue"`
BillingReceiptID string `json:"billingReceiptId"`
}
type cpRelationshipDTO struct {
RelationshipID string `json:"relationshipId"`
RelationType string `json:"relationType"`
Status string `json:"status"`
UserA cpUserDTO `json:"userA"`
UserB cpUserDTO `json:"userB"`
IntimacyValue int64 `json:"intimacyValue"`
Level int32 `json:"level"`
DurationDays int64 `json:"durationDays"`
Gift *cpGiftSnapshotDTO `json:"gift,omitempty"`
FormedAtMS int64 `json:"formedAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
EndedAtMS int64 `json:"endedAtMs"`
}
type cpRelationshipPageDTO struct {
Items []cpRelationshipDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
type cpApplicationDTO struct {
ApplicationID string `json:"applicationId"`
RelationType string `json:"relationType"`
Status string `json:"status"`
Requester cpUserDTO `json:"requester"`
Target cpUserDTO `json:"target"`
Gift cpGiftSnapshotDTO `json:"gift"`
RoomID string `json:"roomId"`
RoomRegionID int64 `json:"roomRegionId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
ExpiresAtMS int64 `json:"expiresAtMs"`
DecidedAtMS int64 `json:"decidedAtMs"`
}
type cpApplicationPageDTO struct {
Items []cpApplicationDTO `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
ServerTimeMS int64 `json:"serverTimeMs"`
}
// ListRelationships 给管理端按全站维度分页读取关系;这里不能复用 App 侧按单用户读取的仓储,否则会漏掉“所有用户关系”这个运营视角。
func (s *Service) ListRelationships(ctx context.Context, appCode string, options cpListOptions) (cpRelationshipPageDTO, error) {
options = normalizeCPListOptions(options)
nowMS := time.Now().UTC().UnixMilli()
whereSQL, args, err := relationshipListWhere(appCode, options)
if err != nil {
return cpRelationshipPageDTO{}, err
}
if s == nil || s.db == nil {
return cpRelationshipPageDTO{}, errorsUserDBNotConfigured()
}
total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_relationships r WHERE "+whereSQL, args...)
if err != nil {
return cpRelationshipPageDTO{}, err
}
queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options))
rows, err := s.db.QueryContext(ctx, `
SELECT
r.relationship_id, r.relation_type, r.status,
r.user_a_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''),
r.user_b_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, ''),
r.intimacy_value, r.level_no, r.formed_at_ms, r.updated_at_ms, r.ended_at_ms,
COALESCE(source.gift_id, ''), COALESCE(source.gift_name, ''), COALESCE(source.gift_icon_url, ''),
COALESCE(source.gift_animation_url, ''), COALESCE(source.gift_count, 0), COALESCE(source.gift_value, 0),
COALESCE(source.billing_receipt_id, '')
FROM user_cp_relationships r
LEFT JOIN users user_a ON user_a.app_code = r.app_code AND user_a.user_id = r.user_a_id
LEFT JOIN users user_b ON user_b.app_code = r.app_code AND user_b.user_id = r.user_b_id
LEFT JOIN user_cp_applications source ON source.app_code = r.app_code AND source.application_id = r.source_application_id
WHERE `+whereSQL+`
ORDER BY r.formed_at_ms DESC, r.relationship_id DESC
LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return cpRelationshipPageDTO{}, err
}
defer rows.Close()
items, err := scanRelationshipRows(rows, nowMS)
if err != nil {
return cpRelationshipPageDTO{}, err
}
return cpRelationshipPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil
}
// ListApplications 读取所有用户的申请记录;读取前先沿用 App 侧懒过期语义,避免后台把已过期申请继续展示为 pending。
func (s *Service) ListApplications(ctx context.Context, appCode string, options cpListOptions) (cpApplicationPageDTO, error) {
options = normalizeCPListOptions(options)
nowMS := time.Now().UTC().UnixMilli()
whereSQL, args, err := applicationListWhere(appCode, options)
if err != nil {
return cpApplicationPageDTO{}, err
}
if s == nil || s.db == nil {
return cpApplicationPageDTO{}, errorsUserDBNotConfigured()
}
if err := s.expirePendingApplications(ctx, appCode, nowMS); err != nil {
return cpApplicationPageDTO{}, err
}
total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_applications a WHERE "+whereSQL, args...)
if err != nil {
return cpApplicationPageDTO{}, err
}
queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options))
rows, err := s.db.QueryContext(ctx, `
SELECT
a.application_id, a.relation_type, a.status,
a.requester_user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''),
a.target_user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, ''),
a.gift_id, a.gift_name, a.gift_icon_url, a.gift_animation_url, a.gift_count, a.gift_value, a.billing_receipt_id,
a.room_id, a.room_region_id, a.created_at_ms, a.updated_at_ms, a.expires_at_ms, a.decided_at_ms
FROM user_cp_applications a
LEFT JOIN users requester ON requester.app_code = a.app_code AND requester.user_id = a.requester_user_id
LEFT JOIN users target ON target.app_code = a.app_code AND target.user_id = a.target_user_id
WHERE `+whereSQL+`
ORDER BY a.created_at_ms DESC, a.application_id DESC
LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return cpApplicationPageDTO{}, err
}
defer rows.Close()
items, err := scanApplicationRows(rows)
if err != nil {
return cpApplicationPageDTO{}, err
}
return cpApplicationPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil
}
func (s *Service) expirePendingApplications(ctx context.Context, appCode string, nowMS int64) error {
_, err := s.db.ExecContext(ctx, `
UPDATE user_cp_applications
SET status = 'expired', updated_at_ms = ?
WHERE app_code = ? AND status = 'pending' AND expires_at_ms > 0 AND expires_at_ms <= ?`,
nowMS, appCode, nowMS,
)
return err
}
func scanRelationshipRows(rows *sql.Rows, nowMS int64) ([]cpRelationshipDTO, error) {
items := []cpRelationshipDTO{}
for rows.Next() {
var item cpRelationshipDTO
var gift cpGiftSnapshotDTO
if err := rows.Scan(
&item.RelationshipID, &item.RelationType, &item.Status,
&item.UserA.UserID, &item.UserA.DisplayUserID, &item.UserA.Username, &item.UserA.Avatar,
&item.UserB.UserID, &item.UserB.DisplayUserID, &item.UserB.Username, &item.UserB.Avatar,
&item.IntimacyValue, &item.Level, &item.FormedAtMS, &item.UpdatedAtMS, &item.EndedAtMS,
&gift.GiftID, &gift.GiftName, &gift.GiftIconURL, &gift.GiftAnimationURL, &gift.GiftCount, &gift.GiftValue, &gift.BillingReceiptID,
); err != nil {
return nil, err
}
item.UserA = normalizeCPUser(item.UserA)
item.UserB = normalizeCPUser(item.UserB)
item.DurationDays = relationshipDurationDays(item.FormedAtMS, item.EndedAtMS, nowMS)
if !gift.empty() {
item.Gift = &gift
}
items = append(items, item)
}
return items, rows.Err()
}
func scanApplicationRows(rows *sql.Rows) ([]cpApplicationDTO, error) {
items := []cpApplicationDTO{}
for rows.Next() {
var item cpApplicationDTO
if err := rows.Scan(
&item.ApplicationID, &item.RelationType, &item.Status,
&item.Requester.UserID, &item.Requester.DisplayUserID, &item.Requester.Username, &item.Requester.Avatar,
&item.Target.UserID, &item.Target.DisplayUserID, &item.Target.Username, &item.Target.Avatar,
&item.Gift.GiftID, &item.Gift.GiftName, &item.Gift.GiftIconURL, &item.Gift.GiftAnimationURL, &item.Gift.GiftCount, &item.Gift.GiftValue, &item.Gift.BillingReceiptID,
&item.RoomID, &item.RoomRegionID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.ExpiresAtMS, &item.DecidedAtMS,
); err != nil {
return nil, err
}
item.Requester = normalizeCPUser(item.Requester)
item.Target = normalizeCPUser(item.Target)
items = append(items, item)
}
return items, rows.Err()
}
func relationshipListWhere(appCode string, options cpListOptions) (string, []any, error) {
where := []string{"r.app_code = ?"}
args := []any{appCode}
if err := appendRelationFilter(&where, &args, "r", options.RelationType); err != nil {
return "", nil, err
}
if err := appendStatusFilter(&where, &args, "r", options.Status, relationshipStatuses, "关系状态不正确"); err != nil {
return "", nil, err
}
return strings.Join(where, " AND "), args, nil
}
func applicationListWhere(appCode string, options cpListOptions) (string, []any, error) {
where := []string{"a.app_code = ?"}
args := []any{appCode}
if err := appendRelationFilter(&where, &args, "a", options.RelationType); err != nil {
return "", nil, err
}
if err := appendStatusFilter(&where, &args, "a", options.Status, applicationStatuses, "申请状态不正确"); err != nil {
return "", nil, err
}
return strings.Join(where, " AND "), args, nil
}
func appendRelationFilter(where *[]string, args *[]any, alias string, value string) error {
relationType := normalizeCPFilterValue(value)
if relationType == "" {
return nil
}
if !slices.Contains(relationTypes, relationType) {
return fmt.Errorf("%w: 关系类型不正确", ErrInvalidArgument)
}
*where = append(*where, alias+".relation_type = ?")
*args = append(*args, relationType)
return nil
}
func appendStatusFilter(where *[]string, args *[]any, alias string, value string, allowed []string, message string) error {
status := normalizeCPFilterValue(value)
if status == "" {
return nil
}
if !slices.Contains(allowed, status) {
return fmt.Errorf("%w: %s", ErrInvalidArgument, message)
}
*where = append(*where, alias+".status = ?")
*args = append(*args, status)
return nil
}
func normalizeCPListOptions(options cpListOptions) cpListOptions {
if options.Page < 1 {
options.Page = 1
}
if options.PageSize < 1 {
options.PageSize = cpListDefaultPageSize
}
if options.PageSize > cpListMaxPageSize {
options.PageSize = cpListMaxPageSize
}
options.Status = normalizeCPFilterValue(options.Status)
options.RelationType = normalizeCPFilterValue(options.RelationType)
return options
}
func normalizeCPFilterValue(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "all" {
return ""
}
return value
}
func cpListOffset(options cpListOptions) int {
return (options.Page - 1) * options.PageSize
}
func (s *Service) countRows(ctx context.Context, query string, args ...any) (int64, error) {
var total int64
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func normalizeCPUser(user cpUserDTO) cpUserDTO {
if strings.TrimSpace(user.DisplayUserID) == "" && user.UserID > 0 {
user.DisplayUserID = strconv.FormatInt(user.UserID, 10)
}
return user
}
func relationshipDurationDays(formedAtMS int64, endedAtMS int64, nowMS int64) int64 {
if formedAtMS <= 0 {
return 0
}
endAtMS := nowMS
if endedAtMS > 0 {
endAtMS = endedAtMS
}
if endAtMS < formedAtMS {
return 0
}
// 运营侧“天数”按自然关系天展示:刚建立也算第 1 天,避免表格出现 0 天这种难以理解的状态。
return ((endAtMS - formedAtMS) / dayMS) + 1
}
func (gift cpGiftSnapshotDTO) empty() bool {
return strings.TrimSpace(gift.GiftID) == "" &&
strings.TrimSpace(gift.GiftName) == "" &&
strings.TrimSpace(gift.GiftIconURL) == "" &&
strings.TrimSpace(gift.GiftAnimationURL) == "" &&
strings.TrimSpace(gift.BillingReceiptID) == "" &&
gift.GiftCount == 0 &&
gift.GiftValue == 0
}
func errorsUserDBNotConfigured() error {
return fmt.Errorf("user db is not configured")
}