feat: live module and jenkins pipeline to harbor deploy
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
bizlive "kra/internal/biz/live"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrendPoint struct {
|
||||
Date string
|
||||
AmountCents int64
|
||||
}
|
||||
type GiftRank struct {
|
||||
RoomID uint
|
||||
RoomName, AnchorName string
|
||||
Diamonds int64
|
||||
}
|
||||
type Dashboard struct {
|
||||
RoomTotal, RoomLive, RechargeCents, PaidOrderCount, GiftDiamonds, GiftSendCount, DiamondRateCents int64
|
||||
RechargeTrend []*TrendPoint
|
||||
GiftRanking []*GiftRank
|
||||
}
|
||||
type Repo interface {
|
||||
Dashboard(context.Context, time.Time, time.Time) (*Dashboard, error)
|
||||
}
|
||||
type Usecase struct {
|
||||
repo Repo
|
||||
streams *bizlive.StreamUsecase
|
||||
}
|
||||
|
||||
func NewUsecase(repo Repo, streams *bizlive.StreamUsecase) *Usecase {
|
||||
return &Usecase{repo: repo, streams: streams}
|
||||
}
|
||||
func (uc *Usecase) Get(ctx context.Context, from, to time.Time) (*Dashboard, error) {
|
||||
v, err := uc.repo.Dashboard(ctx, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
streams, err := uc.streams.List(ctx)
|
||||
if err == nil {
|
||||
v.RoomLive = int64(len(streams))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package analytics
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewUsecase)
|
||||
@@ -0,0 +1,90 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = kratoserrors.NotFound("ASSET_USER_NOT_FOUND", "user asset not found")
|
||||
ErrInsufficient = kratoserrors.BadRequest("ASSET_INSUFFICIENT", "insufficient diamond balance")
|
||||
ErrInvalidAdjustment = kratoserrors.BadRequest("ASSET_ADJUSTMENT_INVALID", "invalid balance adjustment")
|
||||
ErrSelfDisable = kratoserrors.BadRequest("ASSET_SELF_DISABLE", "current administrator cannot be disabled")
|
||||
)
|
||||
|
||||
type User struct {
|
||||
UserID uint
|
||||
Username, Nickname, Avatar string
|
||||
Enabled bool
|
||||
DiamondBalance, TotalRechargeCents, TotalSpentDiamonds int64
|
||||
CreatedAt time.Time
|
||||
LastLoginAt *time.Time
|
||||
RecentOrders []OrderRecord
|
||||
RecentSends []SendRecord
|
||||
}
|
||||
type OrderRecord struct {
|
||||
OrderNo string
|
||||
Diamonds, AmountCents int64
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type SendRecord struct {
|
||||
SerialNo, RoomName, GiftName, GiftIcon string
|
||||
DiamondTotal int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type Filter struct {
|
||||
Keyword string
|
||||
UserID uint
|
||||
Enabled *bool
|
||||
CreatedAt []time.Time
|
||||
}
|
||||
type ListOptions struct {
|
||||
Offset, Limit int
|
||||
OrderBy string
|
||||
}
|
||||
type Adjustment struct {
|
||||
UserID, OperatorUserID uint
|
||||
Type string
|
||||
Amount int64
|
||||
Reason string
|
||||
}
|
||||
type Repo interface {
|
||||
List(context.Context, Filter, ListOptions) ([]*User, int64, error)
|
||||
Get(context.Context, uint) (*User, error)
|
||||
SetEnabled(context.Context, uint, bool) error
|
||||
Adjust(context.Context, *Adjustment) (*User, error)
|
||||
}
|
||||
type Usecase struct{ repo Repo }
|
||||
|
||||
func NewUsecase(repo Repo) *Usecase { return &Usecase{repo: repo} }
|
||||
func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*User, int64, error) {
|
||||
if o.Limit < 1 || o.Limit > 100 {
|
||||
o.Limit = 10
|
||||
}
|
||||
return uc.repo.List(ctx, f, o)
|
||||
}
|
||||
func (uc *Usecase) Get(ctx context.Context, id uint) (*User, error) {
|
||||
if id == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return uc.repo.Get(ctx, id)
|
||||
}
|
||||
func (uc *Usecase) SetEnabled(ctx context.Context, actor, id uint, enabled bool) error {
|
||||
if actor == id && !enabled {
|
||||
return ErrSelfDisable
|
||||
}
|
||||
return uc.repo.SetEnabled(ctx, id, enabled)
|
||||
}
|
||||
func (uc *Usecase) Adjust(ctx context.Context, v *Adjustment) (*User, error) {
|
||||
if v == nil || v.UserID == 0 || v.OperatorUserID == 0 || v.Amount < 1 || v.Amount > 99999 || (v.Type != "increase" && v.Type != "decrease") {
|
||||
return nil, ErrInvalidAdjustment
|
||||
}
|
||||
v.Reason = strings.TrimSpace(v.Reason)
|
||||
if n := len([]rune(v.Reason)); n < 5 || n > 50 {
|
||||
return nil, ErrInvalidAdjustment
|
||||
}
|
||||
return uc.repo.Adjust(ctx, v)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package asset
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewUsecase)
|
||||
+9
-1
@@ -1,8 +1,16 @@
|
||||
package biz
|
||||
|
||||
import (
|
||||
"kra/internal/biz/analytics"
|
||||
"kra/internal/biz/asset"
|
||||
"kra/internal/biz/gift"
|
||||
"kra/internal/biz/integration"
|
||||
"kra/internal/biz/live"
|
||||
"kra/internal/biz/livechat"
|
||||
"kra/internal/biz/liveclient"
|
||||
"kra/internal/biz/payment"
|
||||
"kra/internal/biz/platformsetting"
|
||||
"kra/internal/biz/recharge"
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/biz/task"
|
||||
|
||||
@@ -10,4 +18,4 @@ import (
|
||||
)
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet)
|
||||
var ProviderSet = wire.NewSet(live.ProviderSet, livechat.ProviderSet, liveclient.ProviderSet, gift.ProviderSet, recharge.ProviderSet, asset.ProviderSet, platformsetting.ProviderSet, analytics.ProviderSet, system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package gift
|
||||
|
||||
import (
|
||||
"context"
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = kratoserrors.NotFound("GIFT_NOT_FOUND", "gift not found")
|
||||
ErrNameExists = kratoserrors.BadRequest("GIFT_NAME_EXISTS", "gift name already exists")
|
||||
ErrInvalid = kratoserrors.BadRequest("GIFT_INVALID", "invalid gift data")
|
||||
ErrLastEnabled = kratoserrors.BadRequest("GIFT_LAST_ENABLED", "at least one enabled gift is required")
|
||||
ErrInsufficientBalance = kratoserrors.BadRequest("GIFT_INSUFFICIENT_BALANCE", "insufficient diamond balance")
|
||||
ErrSendNotAllowed = kratoserrors.BadRequest("GIFT_SEND_NOT_ALLOWED", "gift cannot be sent")
|
||||
)
|
||||
|
||||
type Gift struct {
|
||||
ID uint
|
||||
Name, Icon, Description string
|
||||
Price int64
|
||||
Tiers []int
|
||||
Sort int
|
||||
Enabled bool
|
||||
SendCount int64
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
type Send struct {
|
||||
ID uint
|
||||
SerialNo string
|
||||
UserID uint
|
||||
Username, Nickname string
|
||||
RoomID *uint
|
||||
RoomName string
|
||||
RoomDeleted bool
|
||||
AnchorUserID uint
|
||||
AnchorName string
|
||||
GiftID *uint
|
||||
GiftName, GiftIcon string
|
||||
GiftPrice int64
|
||||
Quantity int
|
||||
DiamondTotal, RateCents, EquivalentCents int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type Filter struct {
|
||||
Name string
|
||||
MinPrice, MaxPrice int64
|
||||
Enabled *bool
|
||||
}
|
||||
type SendFilter struct {
|
||||
RoomName, AnchorName, UserKeyword string
|
||||
GiftID uint
|
||||
CreatedAt []time.Time
|
||||
}
|
||||
type ListOptions struct {
|
||||
Offset, Limit int
|
||||
OrderBy string
|
||||
}
|
||||
type Summary struct{ Count, Diamonds, AmountCents int64 }
|
||||
type Repo interface {
|
||||
List(context.Context, Filter, ListOptions) ([]*Gift, int64, error)
|
||||
Get(context.Context, uint) (*Gift, error)
|
||||
Save(context.Context, *Gift) (*Gift, error)
|
||||
Delete(context.Context, uint) error
|
||||
SetEnabled(context.Context, uint, bool) error
|
||||
SaveSort(context.Context, map[uint]int) error
|
||||
ListSends(context.Context, SendFilter, ListOptions) ([]*Send, int64, *Summary, error)
|
||||
Send(context.Context, uint, uint, uint, int) (*Send, error)
|
||||
}
|
||||
type Notifier interface {
|
||||
GiftSent(context.Context, *Send)
|
||||
}
|
||||
type Usecase struct {
|
||||
repo Repo
|
||||
notifier Notifier
|
||||
}
|
||||
|
||||
func NewUsecase(repo Repo, notifier Notifier) *Usecase {
|
||||
return &Usecase{repo: repo, notifier: notifier}
|
||||
}
|
||||
func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Gift, int64, error) {
|
||||
f.Name = strings.TrimSpace(f.Name)
|
||||
return uc.repo.List(ctx, f, normalize(o))
|
||||
}
|
||||
func (uc *Usecase) Get(ctx context.Context, id uint) (*Gift, error) {
|
||||
if id == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return uc.repo.Get(ctx, id)
|
||||
}
|
||||
func (uc *Usecase) Save(ctx context.Context, item *Gift) (*Gift, error) {
|
||||
if err := validate(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.Save(ctx, item)
|
||||
}
|
||||
func (uc *Usecase) Delete(ctx context.Context, id uint) error { return uc.repo.Delete(ctx, id) }
|
||||
func (uc *Usecase) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
return uc.repo.SetEnabled(ctx, id, enabled)
|
||||
}
|
||||
func (uc *Usecase) SaveSort(ctx context.Context, values map[uint]int) error {
|
||||
for id, v := range values {
|
||||
if id == 0 || v < 0 || v > 9999 {
|
||||
return ErrInvalid
|
||||
}
|
||||
}
|
||||
return uc.repo.SaveSort(ctx, values)
|
||||
}
|
||||
func (uc *Usecase) ListSends(ctx context.Context, f SendFilter, o ListOptions) ([]*Send, int64, *Summary, error) {
|
||||
return uc.repo.ListSends(ctx, f, normalize(o))
|
||||
}
|
||||
func (uc *Usecase) Send(ctx context.Context, userID, roomID, giftID uint, quantity int) (*Send, error) {
|
||||
if userID == 0 || roomID == 0 || giftID == 0 || quantity < 1 || quantity > 9999 {
|
||||
return nil, ErrSendNotAllowed
|
||||
}
|
||||
result, err := uc.repo.Send(ctx, userID, roomID, giftID, quantity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uc.notifier != nil {
|
||||
uc.notifier.GiftSent(ctx, result)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func normalize(o ListOptions) ListOptions {
|
||||
if o.Offset < 0 {
|
||||
o.Offset = 0
|
||||
}
|
||||
if o.Limit < 1 || o.Limit > 100 {
|
||||
o.Limit = 10
|
||||
}
|
||||
return o
|
||||
}
|
||||
func validate(item *Gift) error {
|
||||
if item == nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
item.Icon = strings.TrimSpace(item.Icon)
|
||||
item.Description = strings.TrimSpace(item.Description)
|
||||
if len([]rune(item.Name)) < 1 || len([]rune(item.Name)) > 12 || item.Icon == "" || item.Price < 1 || item.Price > 99999 || len([]rune(item.Description)) > 50 || item.Sort < 0 || item.Sort > 9999 || len(item.Tiers) < 1 || len(item.Tiers) > 6 {
|
||||
return ErrInvalid
|
||||
}
|
||||
seen := map[int]struct{}{}
|
||||
for _, v := range item.Tiers {
|
||||
if v < 1 || v > 9999 {
|
||||
return ErrInvalid
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
return ErrInvalid
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
sort.Ints(item.Tiers)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package gift
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewUsecase)
|
||||
@@ -0,0 +1,330 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
RoomStatusOffline = "offline"
|
||||
RoomStatusLive = "live"
|
||||
RoomStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRoomNotFound = kratoserrors.NotFound("LIVE_ROOM_NOT_FOUND", "live room not found")
|
||||
ErrCategoryNotFound = kratoserrors.NotFound("LIVE_CATEGORY_NOT_FOUND", "live category not found")
|
||||
ErrCategoryNameExists = kratoserrors.BadRequest("LIVE_CATEGORY_NAME_EXISTS", "category name already exists")
|
||||
ErrCategoryInUse = kratoserrors.BadRequest("LIVE_CATEGORY_IN_USE", "category is still used by live rooms")
|
||||
ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled")
|
||||
ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting")
|
||||
ErrRoomInvalidArgument = kratoserrors.BadRequest("LIVE_ROOM_INVALID_ARGUMENT", "invalid live room data")
|
||||
ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found")
|
||||
)
|
||||
|
||||
type Category struct {
|
||||
ID uint
|
||||
Name string
|
||||
Icon string
|
||||
Sort int
|
||||
Enabled bool
|
||||
RoomCount int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
ID uint
|
||||
Name string
|
||||
CategoryID uint
|
||||
CategoryName string
|
||||
CoverURL string
|
||||
Intro string
|
||||
Sort int
|
||||
AnchorUserID uint
|
||||
AnchorName string
|
||||
AnchorAvatar string
|
||||
StreamURL string
|
||||
ReplayURL string
|
||||
Enabled bool
|
||||
Status string
|
||||
ViewerCount int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CategoryFilter struct {
|
||||
Name string
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
type RoomFilter struct {
|
||||
Name string
|
||||
AnchorName string
|
||||
CategoryID uint
|
||||
Status string
|
||||
Enabled *bool
|
||||
CreatedAt []time.Time
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
Offset int
|
||||
Limit int
|
||||
OrderBy string
|
||||
}
|
||||
|
||||
type CatalogRepo interface {
|
||||
ListCategories(context.Context, CategoryFilter, ListOptions) ([]*Category, int64, error)
|
||||
GetCategory(context.Context, uint) (*Category, error)
|
||||
CreateCategory(context.Context, *Category) (*Category, error)
|
||||
UpdateCategory(context.Context, *Category) (*Category, error)
|
||||
DeleteCategory(context.Context, uint) error
|
||||
|
||||
ListRooms(context.Context, RoomFilter, ListOptions) ([]*Room, int64, error)
|
||||
GetRoom(context.Context, uint) (*Room, error)
|
||||
CreateRoom(context.Context, *Room) (*Room, error)
|
||||
UpdateRoom(context.Context, *Room) (*Room, error)
|
||||
DeleteRoom(context.Context, uint) error
|
||||
UpdateRoomEnabled(context.Context, uint, bool) error
|
||||
SaveRoomSort(context.Context, map[uint]int) error
|
||||
}
|
||||
|
||||
type CategoryUsecase struct{ repo CatalogRepo }
|
||||
|
||||
func NewCategoryUsecase(repo CatalogRepo) *CategoryUsecase { return &CategoryUsecase{repo: repo} }
|
||||
|
||||
func (uc *CategoryUsecase) List(ctx context.Context, filter CategoryFilter, options ListOptions) ([]*Category, int64, error) {
|
||||
filter.Name = strings.TrimSpace(filter.Name)
|
||||
return uc.repo.ListCategories(ctx, filter, normalizeListOptions(options))
|
||||
}
|
||||
|
||||
func (uc *CategoryUsecase) Get(ctx context.Context, id uint) (*Category, error) {
|
||||
if id == 0 {
|
||||
return nil, ErrCategoryNotFound
|
||||
}
|
||||
return uc.repo.GetCategory(ctx, id)
|
||||
}
|
||||
|
||||
func (uc *CategoryUsecase) Create(ctx context.Context, item *Category) (*Category, error) {
|
||||
if err := validateCategory(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.CreateCategory(ctx, item)
|
||||
}
|
||||
|
||||
func (uc *CategoryUsecase) Update(ctx context.Context, item *Category) (*Category, error) {
|
||||
if item == nil || item.ID == 0 {
|
||||
return nil, ErrCategoryNotFound
|
||||
}
|
||||
if err := validateCategory(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.UpdateCategory(ctx, item)
|
||||
}
|
||||
|
||||
func (uc *CategoryUsecase) Delete(ctx context.Context, id uint) error {
|
||||
if id == 0 {
|
||||
return ErrCategoryNotFound
|
||||
}
|
||||
return uc.repo.DeleteCategory(ctx, id)
|
||||
}
|
||||
|
||||
type RoomUsecase struct {
|
||||
repo CatalogRepo
|
||||
streams *StreamUsecase
|
||||
}
|
||||
|
||||
func NewRoomUsecase(repo CatalogRepo, streams *StreamUsecase) *RoomUsecase {
|
||||
return &RoomUsecase{repo: repo, streams: streams}
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
|
||||
filter.Name = strings.TrimSpace(filter.Name)
|
||||
filter.AnchorName = strings.TrimSpace(filter.AnchorName)
|
||||
options = normalizeListOptions(options)
|
||||
if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline {
|
||||
return uc.listByRuntimeStatus(ctx, filter, options)
|
||||
}
|
||||
rooms, total, err := uc.repo.ListRooms(ctx, filter, options)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return uc.attachRuntime(ctx, rooms), total, nil
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
|
||||
all := make([]*Room, 0)
|
||||
for offset := 0; ; offset += 100 {
|
||||
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: options.OrderBy})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if len(all) >= int(total) || len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
all = uc.attachRuntime(ctx, all)
|
||||
matched := make([]*Room, 0, len(all))
|
||||
for _, room := range all {
|
||||
if room.Status == filter.Status {
|
||||
matched = append(matched, room)
|
||||
}
|
||||
}
|
||||
total := int64(len(matched))
|
||||
if options.Offset >= len(matched) {
|
||||
return []*Room{}, total, nil
|
||||
}
|
||||
end := options.Offset + options.Limit
|
||||
if end > len(matched) {
|
||||
end = len(matched)
|
||||
}
|
||||
return matched[options.Offset:end], total, nil
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) {
|
||||
if id == 0 {
|
||||
return nil, ErrRoomNotFound
|
||||
}
|
||||
room, err := uc.repo.GetRoom(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.attachRuntime(ctx, []*Room{room})[0], nil
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) {
|
||||
if err := validateRoom(room); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.CreateRoom(ctx, room)
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) Update(ctx context.Context, room *Room) (*Room, error) {
|
||||
if room == nil || room.ID == 0 {
|
||||
return nil, ErrRoomNotFound
|
||||
}
|
||||
if err := validateRoom(room); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := uc.Get(ctx, room.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.Status == RoomStatusLive && current.CategoryID != room.CategoryID {
|
||||
return nil, ErrRoomCategoryLocked
|
||||
}
|
||||
return uc.repo.UpdateRoom(ctx, room)
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) Delete(ctx context.Context, id uint) error {
|
||||
if id == 0 {
|
||||
return ErrRoomNotFound
|
||||
}
|
||||
return uc.repo.DeleteRoom(ctx, id)
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
if id == 0 {
|
||||
return ErrRoomNotFound
|
||||
}
|
||||
return uc.repo.UpdateRoomEnabled(ctx, id, enabled)
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) SaveSort(ctx context.Context, values map[uint]int) error {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
for id, sortValue := range values {
|
||||
if id == 0 || sortValue < 0 || sortValue > 9999 {
|
||||
return ErrRoomInvalidArgument
|
||||
}
|
||||
}
|
||||
return uc.repo.SaveRoomSort(ctx, values)
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) attachRuntime(ctx context.Context, rooms []*Room) []*Room {
|
||||
streams, err := uc.streams.List(ctx)
|
||||
if err != nil {
|
||||
return rooms
|
||||
}
|
||||
byID := make(map[string]*Stream, len(streams))
|
||||
for _, stream := range streams {
|
||||
byID[stream.StreamID] = stream
|
||||
}
|
||||
for _, room := range rooms {
|
||||
if !room.Enabled {
|
||||
room.Status = RoomStatusDisabled
|
||||
continue
|
||||
}
|
||||
room.Status = RoomStatusOffline
|
||||
if stream := byID[roomStreamID(room.ID)]; stream != nil && stream.PublisherOnline {
|
||||
room.Status = RoomStatusLive
|
||||
room.ViewerCount = stream.SubscriberCount
|
||||
}
|
||||
}
|
||||
return rooms
|
||||
}
|
||||
|
||||
func roomStreamID(id uint) string { return "room-" + formatUint(id) }
|
||||
|
||||
func formatUint(value uint) string {
|
||||
const digits = "0123456789"
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
index := len(buf)
|
||||
for value > 0 {
|
||||
index--
|
||||
buf[index] = digits[value%10]
|
||||
value /= 10
|
||||
}
|
||||
return string(buf[index:])
|
||||
}
|
||||
|
||||
func normalizeListOptions(options ListOptions) ListOptions {
|
||||
if options.Offset < 0 {
|
||||
options.Offset = 0
|
||||
}
|
||||
if options.Limit <= 0 || options.Limit > 100 {
|
||||
options.Limit = 10
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func validateCategory(item *Category) error {
|
||||
if item == nil {
|
||||
return ErrRoomInvalidArgument
|
||||
}
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
item.Icon = strings.TrimSpace(item.Icon)
|
||||
if length := len([]rune(item.Name)); length < 1 || length > 10 || item.Sort < 0 || item.Sort > 9999 {
|
||||
return ErrRoomInvalidArgument
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoom(room *Room) error {
|
||||
if room == nil {
|
||||
return ErrRoomInvalidArgument
|
||||
}
|
||||
room.Name = strings.TrimSpace(room.Name)
|
||||
room.Intro = strings.TrimSpace(room.Intro)
|
||||
room.CoverURL = strings.TrimSpace(room.CoverURL)
|
||||
room.StreamURL = strings.TrimSpace(room.StreamURL)
|
||||
room.ReplayURL = strings.TrimSpace(room.ReplayURL)
|
||||
if nameLength := len([]rune(room.Name)); nameLength < 1 || nameLength > 30 {
|
||||
return ErrRoomInvalidArgument
|
||||
}
|
||||
if len([]rune(room.Intro)) > 60 || room.CategoryID == 0 || room.AnchorUserID == 0 || room.CoverURL == "" || room.Sort < 0 || room.Sort > 9999 {
|
||||
return ErrRoomInvalidArgument
|
||||
}
|
||||
if room.StreamURL != "" && !strings.HasPrefix(room.StreamURL, "http://") && !strings.HasPrefix(room.StreamURL, "https://") {
|
||||
return ErrRoomInvalidArgument
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package live
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase)
|
||||
@@ -0,0 +1,84 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxStreams = 20
|
||||
StatusWaiting = "waiting"
|
||||
StatusLive = "live"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidStreamID = errors.New("invalid stream id")
|
||||
ErrMaxStreams = errors.New("maximum live streams reached")
|
||||
ErrPublisherExists = errors.New("publisher already exists")
|
||||
ErrPublisherNotFound = errors.New("publisher not found")
|
||||
ErrSubscriberExists = errors.New("subscriber already exists")
|
||||
ErrSubscriberNotFound = errors.New("subscriber not found")
|
||||
)
|
||||
|
||||
type Stream struct {
|
||||
StreamID string
|
||||
PublisherID uint
|
||||
PublisherOnline bool
|
||||
SubscriberCount int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type StreamRepo interface {
|
||||
Get(context.Context, string) (*Stream, error)
|
||||
List(context.Context) ([]*Stream, error)
|
||||
RegisterPublisher(context.Context, string, uint) (*Stream, error)
|
||||
UnregisterPublisher(context.Context, string, uint) error
|
||||
RegisterSubscriber(context.Context, string, uint) (*Stream, error)
|
||||
UnregisterSubscriber(context.Context, string, uint) error
|
||||
}
|
||||
|
||||
type StreamUsecase struct{ repo StreamRepo }
|
||||
|
||||
func NewStreamUsecase(repo StreamRepo) *StreamUsecase { return &StreamUsecase{repo: repo} }
|
||||
|
||||
func (uc *StreamUsecase) List(ctx context.Context) ([]*Stream, error) {
|
||||
return uc.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) Get(ctx context.Context, streamID string) (*Stream, error) {
|
||||
streamID = normalizeStreamID(streamID)
|
||||
if streamID == "" {
|
||||
return nil, ErrInvalidStreamID
|
||||
}
|
||||
return uc.repo.Get(ctx, streamID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) RegisterPublisher(ctx context.Context, streamID string, userID uint) (*Stream, error) {
|
||||
streamID = normalizeStreamID(streamID)
|
||||
if streamID == "" || userID == 0 {
|
||||
return nil, ErrInvalidStreamID
|
||||
}
|
||||
return uc.repo.RegisterPublisher(ctx, streamID, userID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) UnregisterPublisher(ctx context.Context, streamID string, userID uint) error {
|
||||
return uc.repo.UnregisterPublisher(ctx, normalizeStreamID(streamID), userID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) RegisterSubscriber(ctx context.Context, streamID string, userID uint) (*Stream, error) {
|
||||
streamID = normalizeStreamID(streamID)
|
||||
if streamID == "" || userID == 0 {
|
||||
return nil, ErrInvalidStreamID
|
||||
}
|
||||
return uc.repo.RegisterSubscriber(ctx, streamID, userID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) UnregisterSubscriber(ctx context.Context, streamID string, userID uint) error {
|
||||
return uc.repo.UnregisterSubscriber(ctx, normalizeStreamID(streamID), userID)
|
||||
}
|
||||
|
||||
func normalizeStreamID(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testRepo struct {
|
||||
streams map[string]*Stream
|
||||
}
|
||||
|
||||
func (r *testRepo) Get(_ context.Context, id string) (*Stream, error) { return r.streams[id], nil }
|
||||
func (r *testRepo) List(context.Context) ([]*Stream, error) {
|
||||
result := make([]*Stream, 0, len(r.streams))
|
||||
for _, item := range r.streams {
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (r *testRepo) RegisterPublisher(_ context.Context, id string, user uint) (*Stream, error) {
|
||||
item := r.streams[id]
|
||||
if item == nil {
|
||||
item = &Stream{StreamID: id}
|
||||
r.streams[id] = item
|
||||
}
|
||||
if item.PublisherOnline {
|
||||
return nil, ErrPublisherExists
|
||||
}
|
||||
item.PublisherOnline, item.PublisherID = true, user
|
||||
return item, nil
|
||||
}
|
||||
func (r *testRepo) UnregisterPublisher(_ context.Context, id string, user uint) error {
|
||||
item := r.streams[id]
|
||||
if item == nil || !item.PublisherOnline || item.PublisherID != user {
|
||||
return ErrPublisherNotFound
|
||||
}
|
||||
item.PublisherOnline, item.PublisherID = false, 0
|
||||
return nil
|
||||
}
|
||||
func (r *testRepo) RegisterSubscriber(context.Context, string, uint) (*Stream, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *testRepo) UnregisterSubscriber(context.Context, string, uint) error { return nil }
|
||||
|
||||
func TestStreamUsecaseValidatesAndDelegates(t *testing.T) {
|
||||
repo := &testRepo{streams: make(map[string]*Stream)}
|
||||
uc := NewStreamUsecase(repo)
|
||||
if _, err := uc.RegisterPublisher(context.Background(), " ", 1); err != ErrInvalidStreamID {
|
||||
t.Fatalf("invalid stream error = %v", err)
|
||||
}
|
||||
if _, err := uc.RegisterPublisher(context.Background(), "room-1", 1); err != nil {
|
||||
t.Fatalf("register publisher error = %v", err)
|
||||
}
|
||||
if _, err := uc.RegisterPublisher(context.Background(), "room-1", 2); err != ErrPublisherExists {
|
||||
t.Fatalf("duplicate publisher error = %v", err)
|
||||
}
|
||||
if err := uc.UnregisterPublisher(context.Background(), "room-1", 1); err != nil {
|
||||
t.Fatalf("unregister publisher error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package livechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeSystem = "system"
|
||||
TypeChat = "chat"
|
||||
TypeGift = "gift"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidMessage = kratoserrors.BadRequest("LIVE_CHAT_INVALID", "invalid chat message")
|
||||
ErrRoomNotFound = kratoserrors.NotFound("LIVE_CHAT_ROOM_NOT_FOUND", "live room not found")
|
||||
ErrUserNotFound = kratoserrors.NotFound("LIVE_CHAT_USER_NOT_FOUND", "chat user not found")
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID uint
|
||||
RoomID uint
|
||||
Type string
|
||||
UserID uint
|
||||
Username string
|
||||
Nickname string
|
||||
Avatar string
|
||||
Content string
|
||||
GiftID uint
|
||||
GiftName string
|
||||
GiftIcon string
|
||||
Quantity int
|
||||
DiamondTotal int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type GiftSnapshot struct {
|
||||
RoomID uint
|
||||
UserID uint
|
||||
Username string
|
||||
Nickname string
|
||||
GiftID uint
|
||||
GiftName string
|
||||
GiftIcon string
|
||||
Quantity int
|
||||
DiamondTotal int64
|
||||
}
|
||||
|
||||
type Repo interface {
|
||||
ListRecent(context.Context, uint, int) ([]*Message, error)
|
||||
CreateChat(context.Context, uint, uint, string) (*Message, error)
|
||||
CreateGift(context.Context, *GiftSnapshot) (*Message, error)
|
||||
}
|
||||
|
||||
type Usecase struct{ repo Repo }
|
||||
|
||||
func NewUsecase(repo Repo) *Usecase { return &Usecase{repo: repo} }
|
||||
|
||||
func (uc *Usecase) ListRecent(ctx context.Context, roomID uint, limit int) ([]*Message, error) {
|
||||
if roomID == 0 {
|
||||
return nil, ErrRoomNotFound
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
return uc.repo.ListRecent(ctx, roomID, limit)
|
||||
}
|
||||
|
||||
func (uc *Usecase) Send(ctx context.Context, roomID, userID uint, content string) (*Message, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if roomID == 0 || userID == 0 || len([]rune(content)) < 1 || len([]rune(content)) > 50 {
|
||||
return nil, ErrInvalidMessage
|
||||
}
|
||||
return uc.repo.CreateChat(ctx, roomID, userID, content)
|
||||
}
|
||||
|
||||
func (uc *Usecase) RecordGift(ctx context.Context, gift *GiftSnapshot) (*Message, error) {
|
||||
if gift == nil || gift.RoomID == 0 || gift.UserID == 0 || gift.GiftID == 0 || gift.Quantity < 1 || gift.DiamondTotal < 1 {
|
||||
return nil, ErrInvalidMessage
|
||||
}
|
||||
return uc.repo.CreateGift(ctx, gift)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package livechat
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewUsecase)
|
||||
@@ -0,0 +1,175 @@
|
||||
package liveclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"kra/internal/biz/system"
|
||||
)
|
||||
|
||||
const (
|
||||
ClientAuthorityID uint = 1000
|
||||
InitialDiamonds int64 = 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidAccount = kratoserrors.BadRequest("LIVE_CLIENT_INVALID", "invalid client account data")
|
||||
ErrUsernameExists = kratoserrors.BadRequest("LIVE_CLIENT_USERNAME_EXISTS", "username already exists")
|
||||
ErrAccountNotFound = kratoserrors.NotFound("LIVE_CLIENT_NOT_FOUND", "client account not found")
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
UserID uint
|
||||
UUID string
|
||||
Username string
|
||||
Nickname string
|
||||
Avatar string
|
||||
AuthorityID uint
|
||||
Enabled bool
|
||||
DiamondBalance int64
|
||||
CreatedAt time.Time
|
||||
PasswordUpdatedAt *time.Time
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Username, Password, ConfirmPassword string
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Username, Password, IP, Agent string
|
||||
}
|
||||
|
||||
type ProfileInput struct {
|
||||
UserID uint
|
||||
Nickname, Avatar string
|
||||
}
|
||||
|
||||
type PasswordInput struct {
|
||||
UserID uint
|
||||
OldPassword, NewPassword, ConfirmPassword string
|
||||
IP, Agent string
|
||||
}
|
||||
|
||||
type AuthResult struct {
|
||||
Account *Account
|
||||
Auth *system.AuthenticationResult
|
||||
}
|
||||
|
||||
type Repo interface {
|
||||
Register(context.Context, *Account, string, int64) (*Account, error)
|
||||
Get(context.Context, uint) (*Account, error)
|
||||
UpdateProfile(context.Context, *ProfileInput) (*Account, error)
|
||||
}
|
||||
|
||||
type Usecase struct {
|
||||
repo Repo
|
||||
security *system.SecurityUsecase
|
||||
auth *system.AuthenticationUsecase
|
||||
users *system.UserUsecase
|
||||
}
|
||||
|
||||
func NewUsecase(repo Repo, security *system.SecurityUsecase, auth *system.AuthenticationUsecase, users *system.UserUsecase) *Usecase {
|
||||
return &Usecase{repo: repo, security: security, auth: auth, users: users}
|
||||
}
|
||||
|
||||
func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent string) (*AuthResult, error) {
|
||||
if in == nil {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
in.Username = strings.TrimSpace(in.Username)
|
||||
if n := len([]rune(in.Username)); n < 3 || n > 32 || in.Password == "" || in.Password != in.ConfirmPassword {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
config, err := uc.security.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = uc.security.ValidatePassword(config, in.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := uc.repo.Register(ctx, &Account{Username: in.Username, Nickname: in.Username, AuthorityID: ClientAuthorityID, Enabled: true}, string(hash), InitialDiamonds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auth, err := uc.auth.AuthenticateRegisteredUser(ctx, accountUser(account), ip, agent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuthResult{Account: account, Auth: auth}, nil
|
||||
}
|
||||
|
||||
func (uc *Usecase) Login(ctx context.Context, in *LoginInput) (*AuthResult, error) {
|
||||
if in == nil || strings.TrimSpace(in.Username) == "" || in.Password == "" {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
auth, err := uc.auth.LoginClient(ctx, &system.LoginAttempt{Username: strings.TrimSpace(in.Username), Password: in.Password, IP: in.IP, Agent: in.Agent})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := uc.repo.Get(ctx, auth.User.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuthResult{Account: account, Auth: auth}, nil
|
||||
}
|
||||
|
||||
func (uc *Usecase) Get(ctx context.Context, userID uint) (*Account, error) {
|
||||
if userID == 0 {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return uc.repo.Get(ctx, userID)
|
||||
}
|
||||
|
||||
func (uc *Usecase) UpdateProfile(ctx context.Context, in *ProfileInput) (*Account, error) {
|
||||
if in == nil || in.UserID == 0 {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
in.Nickname = strings.TrimSpace(in.Nickname)
|
||||
in.Avatar = strings.TrimSpace(in.Avatar)
|
||||
if in.Nickname != "" {
|
||||
if n := len([]rune(in.Nickname)); n < 2 || n > 16 {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
}
|
||||
if len([]rune(in.Avatar)) > 1000 {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
return uc.repo.UpdateProfile(ctx, in)
|
||||
}
|
||||
|
||||
func (uc *Usecase) ChangePassword(ctx context.Context, in *PasswordInput) (*AuthResult, error) {
|
||||
if in == nil || in.UserID == 0 || in.OldPassword == "" || in.NewPassword == "" || in.NewPassword != in.ConfirmPassword {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
config, err := uc.security.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = uc.security.ValidatePassword(config, in.NewPassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = uc.users.ChangePassword(ctx, in.UserID, in.OldPassword, in.NewPassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := uc.repo.Get(ctx, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auth, err := uc.auth.AuthenticateRegisteredUser(ctx, accountUser(account), in.IP, in.Agent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuthResult{Account: account, Auth: auth}, nil
|
||||
}
|
||||
|
||||
func accountUser(account *Account) *system.User {
|
||||
return &system.User{ID: account.UserID, UUID: account.UUID, Username: account.Username, NickName: account.Nickname, HeaderImg: account.Avatar, AuthorityID: account.AuthorityID, Enable: 1, PasswordUpdatedAt: account.PasswordUpdatedAt}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package liveclient
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewUsecase)
|
||||
@@ -538,6 +538,10 @@ func supportedPaymentProvider(provider string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func IsSupportedPaymentProvider(provider string) bool {
|
||||
return supportedPaymentProvider(strings.TrimSpace(provider))
|
||||
}
|
||||
|
||||
func IsDemoPaymentOrder(order *PaymentOrder) bool {
|
||||
return order != nil && order.BusinessType == DemoBusinessType && strings.HasPrefix(strings.TrimSpace(order.TradeNo), "DEMO-PAY-")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package platformsetting
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewUsecase)
|
||||
@@ -0,0 +1,24 @@
|
||||
package platformsetting
|
||||
|
||||
import (
|
||||
"context"
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var ErrInvalid = kratoserrors.BadRequest("PLATFORM_SETTING_INVALID", "diamond rate must be between 0.01 and 1.00")
|
||||
|
||||
type Setting struct{ DiamondRateCents int64 }
|
||||
type Repo interface {
|
||||
Get(context.Context) (*Setting, error)
|
||||
Save(context.Context, *Setting) error
|
||||
}
|
||||
type Usecase struct{ repo Repo }
|
||||
|
||||
func NewUsecase(repo Repo) *Usecase { return &Usecase{repo: repo} }
|
||||
func (uc *Usecase) Get(ctx context.Context) (*Setting, error) { return uc.repo.Get(ctx) }
|
||||
func (uc *Usecase) Save(ctx context.Context, v *Setting) error {
|
||||
if v == nil || v.DiamondRateCents < 1 || v.DiamondRateCents > 100 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return uc.repo.Save(ctx, v)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package recharge
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewUsecase)
|
||||
@@ -0,0 +1,197 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
)
|
||||
|
||||
const BusinessType = "live_recharge"
|
||||
|
||||
var (
|
||||
ErrNotFound = kratoserrors.NotFound("RECHARGE_ORDER_NOT_FOUND", "recharge order not found")
|
||||
ErrInvalid = kratoserrors.BadRequest("RECHARGE_INVALID", "invalid recharge data")
|
||||
ErrRefundUnsupported = kratoserrors.BadRequest("RECHARGE_REFUND_UNSUPPORTED", "recharge refunds require manual balance reconciliation")
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
ID uint
|
||||
Sort int
|
||||
Diamonds, AmountCents int64
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Order owns recharge facts. Payment state comes from KRA's payment order.
|
||||
type Order struct {
|
||||
ID uint
|
||||
OrderNo string
|
||||
UserID uint
|
||||
Username, Nickname string
|
||||
Diamonds, AmountCents int64
|
||||
Provider, ProviderTradeNo string
|
||||
PaymentStatus, FulfillmentStatus, RefundStatus string
|
||||
CreatedAt time.Time
|
||||
CreditedAt *time.Time
|
||||
}
|
||||
|
||||
type Checkout struct {
|
||||
Provider string
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
DeviceID string
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
OrderNo, UserKeyword, PaymentStatus, Provider string
|
||||
CreatedAt []time.Time
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
Offset, Limit int
|
||||
OrderBy string
|
||||
}
|
||||
|
||||
type Summary struct{ Count, PaidCount, AmountCents int64 }
|
||||
|
||||
type Repo interface {
|
||||
Plans(context.Context) ([]*Plan, error)
|
||||
SavePlans(context.Context, []*Plan) error
|
||||
List(context.Context, Filter, ListOptions) ([]*Order, int64, *Summary, error)
|
||||
Get(context.Context, string) (*Order, error)
|
||||
Create(context.Context, uint, uint) (*Order, error)
|
||||
MarkPaid(context.Context, string) (*Order, error)
|
||||
}
|
||||
|
||||
type Usecase struct {
|
||||
repo Repo
|
||||
payments *bizpayment.PaymentUsecase
|
||||
}
|
||||
|
||||
func NewUsecase(repo Repo, payments *bizpayment.PaymentUsecase) (*Usecase, error) {
|
||||
uc := &Usecase{repo: repo, payments: payments}
|
||||
if payments == nil {
|
||||
return nil, fmt.Errorf("充值支付服务未接入")
|
||||
}
|
||||
if err := payments.RegisterBusinessModule(uc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc, nil
|
||||
}
|
||||
|
||||
func (uc *Usecase) Plans(ctx context.Context) ([]*Plan, error) { return uc.repo.Plans(ctx) }
|
||||
|
||||
func (uc *Usecase) SavePlans(ctx context.Context, plans []*Plan) error {
|
||||
if len(plans) < 1 || len(plans) > 8 {
|
||||
return ErrInvalid
|
||||
}
|
||||
for i, p := range plans {
|
||||
if p == nil || p.Diamonds < 1 || p.Diamonds > 99999 || p.AmountCents < 1 || p.AmountCents > 999900 {
|
||||
return ErrInvalid
|
||||
}
|
||||
if p.Sort == 0 {
|
||||
p.Sort = i + 1
|
||||
}
|
||||
p.Enabled = true
|
||||
}
|
||||
return uc.repo.SavePlans(ctx, plans)
|
||||
}
|
||||
|
||||
func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Order, int64, *Summary, error) {
|
||||
if o.Limit < 1 || o.Limit > 100 {
|
||||
o.Limit = 10
|
||||
}
|
||||
return uc.repo.List(ctx, f, o)
|
||||
}
|
||||
|
||||
func (uc *Usecase) Get(ctx context.Context, no string) (*Order, error) {
|
||||
if strings.TrimSpace(no) == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return uc.repo.Get(ctx, no)
|
||||
}
|
||||
|
||||
func (uc *Usecase) Create(ctx context.Context, userID, planID uint, checkout Checkout) (*Order, *bizpayment.PaymentResult, error) {
|
||||
checkout.Provider = strings.TrimSpace(checkout.Provider)
|
||||
if userID == 0 || planID == 0 || checkout.Provider == "" || checkout.Provider == bizpayment.PaymentInternal || !bizpayment.IsSupportedPaymentProvider(checkout.Provider) {
|
||||
return nil, nil, ErrInvalid
|
||||
}
|
||||
order, err := uc.repo.Create(ctx, userID, planID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
extra := make(map[string]any, len(checkout.Extra)+2)
|
||||
for key, value := range checkout.Extra {
|
||||
extra[key] = value
|
||||
}
|
||||
extra["userId"] = order.UserID
|
||||
extra["diamonds"] = order.Diamonds
|
||||
result, err := uc.payments.Create(ctx, &bizpayment.PaymentRequest{
|
||||
Provider: checkout.Provider, TradeNo: order.OrderNo,
|
||||
BusinessType: BusinessType, BusinessID: order.OrderNo,
|
||||
NotifyURL: checkout.NotifyURL, ReturnURL: checkout.ReturnURL,
|
||||
ClientIP: checkout.ClientIP, UserAgent: checkout.UserAgent, DeviceID: checkout.DeviceID,
|
||||
Extra: extra,
|
||||
})
|
||||
if err != nil {
|
||||
return order, nil, err
|
||||
}
|
||||
refreshed, err := uc.repo.Get(ctx, order.OrderNo)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return refreshed, result, nil
|
||||
}
|
||||
|
||||
func (uc *Usecase) Type() string { return BusinessType }
|
||||
|
||||
func (uc *Usecase) PreparePayment(ctx context.Context, provider, tradeNo, businessID string) (*bizpayment.PaymentIntent, error) {
|
||||
if strings.TrimSpace(provider) == "" || tradeNo == "" || businessID != tradeNo {
|
||||
return nil, bizpayment.ErrPaymentOrderConflict
|
||||
}
|
||||
order, err := uc.repo.Get(ctx, businessID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.OrderNo != tradeNo || order.CreditedAt != nil {
|
||||
return nil, bizpayment.ErrPaymentOrderConflict
|
||||
}
|
||||
extra, err := json.Marshal(map[string]any{"userId": order.UserID, "diamonds": order.Diamonds})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &bizpayment.PaymentIntent{
|
||||
Provider: provider, TradeNo: tradeNo, BusinessType: BusinessType, BusinessID: businessID,
|
||||
Subject: fmt.Sprintf("充值 %d 钻石", order.Diamonds), Amount: order.AmountCents,
|
||||
OriginalAmount: order.AmountCents, Currency: "CNY", PaymentMode: bizpayment.PaymentModeExternal,
|
||||
Extra: extra,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (uc *Usecase) Fulfill(ctx context.Context, confirmation *bizpayment.PaymentConfirmation) error {
|
||||
if confirmation == nil || confirmation.BusinessType != BusinessType || confirmation.BusinessID == "" || confirmation.TradeNo != confirmation.BusinessID || !strings.EqualFold(confirmation.Currency, "CNY") {
|
||||
return ErrInvalid
|
||||
}
|
||||
order, err := uc.repo.Get(ctx, confirmation.BusinessID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if order.AmountCents != confirmation.Amount {
|
||||
return bizpayment.ErrPaymentOrderConflict
|
||||
}
|
||||
_, err = uc.repo.MarkPaid(ctx, order.OrderNo)
|
||||
return err
|
||||
}
|
||||
|
||||
// Refunds stay disabled until diamonds can be reserved across an async refund.
|
||||
func (uc *Usecase) AuthorizeRefund(context.Context, *bizpayment.PaymentOrder, int64) error {
|
||||
return ErrRefundUnsupported
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
)
|
||||
|
||||
type repoStub struct {
|
||||
order *Order
|
||||
paidCall int
|
||||
}
|
||||
|
||||
func (r *repoStub) Plans(context.Context) ([]*Plan, error) { return nil, nil }
|
||||
func (r *repoStub) SavePlans(context.Context, []*Plan) error { return nil }
|
||||
func (r *repoStub) List(context.Context, Filter, ListOptions) ([]*Order, int64, *Summary, error) {
|
||||
return nil, 0, &Summary{}, nil
|
||||
}
|
||||
func (r *repoStub) Get(context.Context, string) (*Order, error) { return r.order, nil }
|
||||
func (r *repoStub) Create(context.Context, uint, uint) (*Order, error) { return r.order, nil }
|
||||
func (r *repoStub) MarkPaid(context.Context, string) (*Order, error) {
|
||||
r.paidCall++
|
||||
return r.order, nil
|
||||
}
|
||||
|
||||
func newTestUsecase(t *testing.T, repo Repo) *Usecase {
|
||||
t.Helper()
|
||||
payments := bizpayment.NewPaymentUsecase(nil, nil, bizpayment.NewPaymentHooks(), bizpayment.NewPaymentOrderSourceRegistry(), bizpayment.NewPaymentFulfillmentRegistry(), nil)
|
||||
uc, err := NewUsecase(repo, payments)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return uc
|
||||
}
|
||||
|
||||
func TestPreparePaymentUsesRechargeOrderFacts(t *testing.T) {
|
||||
repo := &repoStub{order: &Order{OrderNo: "CZ-1", UserID: 7, Diamonds: 300, AmountCents: 3000}}
|
||||
uc := newTestUsecase(t, repo)
|
||||
intent, err := uc.PreparePayment(context.Background(), bizpayment.PaymentWechatV3, "CZ-1", "CZ-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if intent.Amount != 3000 || intent.OriginalAmount != 3000 || intent.Currency != "CNY" || intent.Subject != "充值 300 钻石" {
|
||||
t.Fatalf("unexpected payment intent: %+v", intent)
|
||||
}
|
||||
if string(intent.Extra) != `{"diamonds":300,"userId":7}` {
|
||||
t.Fatalf("unexpected payment extra: %s", intent.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillCreditsMatchingRechargeOnly(t *testing.T) {
|
||||
repo := &repoStub{order: &Order{OrderNo: "CZ-1", Diamonds: 60, AmountCents: 600}}
|
||||
uc := newTestUsecase(t, repo)
|
||||
err := uc.Fulfill(context.Background(), &bizpayment.PaymentConfirmation{
|
||||
TradeNo: "CZ-1", BusinessType: BusinessType, BusinessID: "CZ-1", Amount: 600, Currency: "CNY",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.paidCall != 1 {
|
||||
t.Fatalf("paid calls = %d, want 1", repo.paidCall)
|
||||
}
|
||||
err = uc.Fulfill(context.Background(), &bizpayment.PaymentConfirmation{
|
||||
TradeNo: "CZ-1", BusinessType: BusinessType, BusinessID: "CZ-1", Amount: 601, Currency: "CNY",
|
||||
})
|
||||
if err == nil || repo.paidCall != 1 {
|
||||
t.Fatalf("mismatched payment was credited: err=%v calls=%d", err, repo.paidCall)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,69 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
|
||||
}
|
||||
|
||||
uc.security.ClearLoginState(ctx, attempt.Username)
|
||||
return uc.issueAuthenticatedUser(ctx, user, attempt)
|
||||
}
|
||||
|
||||
// LoginClient authenticates the public live client without the administration
|
||||
// captcha challenge. Account locking, credential checks, audit logging and
|
||||
// multipoint-session rotation still use the same security policy as the admin
|
||||
// login flow.
|
||||
func (uc *AuthenticationUsecase) LoginClient(ctx context.Context, attempt *LoginAttempt) (*AuthenticationResult, error) {
|
||||
config, err := uc.security.Current(ctx)
|
||||
if err != nil || config == nil {
|
||||
return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState)
|
||||
}
|
||||
if config.LockEnable {
|
||||
locked, lockErr := uc.security.LoginLocked(ctx, attempt.Username)
|
||||
if lockErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr)
|
||||
}
|
||||
if locked {
|
||||
uc.recordLogin(ctx, attempt, false, "账号已锁定", 0)
|
||||
return nil, &AccountLockedError{Minutes: config.LockDuration}
|
||||
}
|
||||
}
|
||||
user, err := uc.users.Login(ctx, attempt.Username, attempt.Password)
|
||||
if err != nil {
|
||||
if config.LockEnable {
|
||||
lockTTL := time.Duration(config.LockDuration) * time.Minute
|
||||
failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL)
|
||||
if stateErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr)
|
||||
}
|
||||
if int(failures) >= config.LockThreshold {
|
||||
if stateErr = uc.security.LockLogin(ctx, attempt.Username, lockTTL); stateErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
uc.recordLogin(ctx, attempt, false, "用户名不存在或者密码错误", 0)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if user.Enable != 1 {
|
||||
uc.recordLogin(ctx, attempt, false, "用户被禁止登录", user.ID)
|
||||
return nil, &UserDisabledError{UserID: user.ID}
|
||||
}
|
||||
uc.security.ClearLoginState(ctx, attempt.Username)
|
||||
return uc.issueAuthenticatedUser(ctx, user, attempt)
|
||||
}
|
||||
|
||||
// AuthenticateRegisteredUser issues the first session immediately after a
|
||||
// client registration. Credential verification has already succeeded by
|
||||
// creating the account, so this method only applies the common token/session
|
||||
// rules.
|
||||
func (uc *AuthenticationUsecase) AuthenticateRegisteredUser(ctx context.Context, user *User, ip, agent string) (*AuthenticationResult, error) {
|
||||
if user == nil || user.ID == 0 || user.Enable != 1 {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return uc.issueAuthenticatedUser(ctx, user, &LoginAttempt{Username: user.Username, IP: ip, Agent: agent})
|
||||
}
|
||||
|
||||
func (uc *AuthenticationUsecase) issueAuthenticatedUser(ctx context.Context, user *User, attempt *LoginAttempt) (*AuthenticationResult, error) {
|
||||
config, err := uc.security.Current(ctx)
|
||||
if err != nil || config == nil {
|
||||
return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState)
|
||||
}
|
||||
if passwordExpired(config, user) {
|
||||
user.MustChangePassword = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user