feat: live module and jenkins pipeline to harbor deploy

This commit is contained in:
Yvan
2026-09-21 16:45:44 +08:00
parent 451c8e2abc
commit 829d75c657
145 changed files with 8984 additions and 269 deletions
+44
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
package analytics
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewUsecase)
+90
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
package asset
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewUsecase)
+9 -1
View File
@@ -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)
+158
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
package gift
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewUsecase)
+330
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
package live
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase)
+84
View File
@@ -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)
}
+60
View File
@@ -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)
}
}
+85
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
package livechat
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewUsecase)
+175
View File
@@ -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}
}
+5
View File
@@ -0,0 +1,5 @@
package liveclient
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewUsecase)
+4
View File
@@ -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-")
}
+5
View File
@@ -0,0 +1,5 @@
package platformsetting
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewUsecase)
+24
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
package recharge
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewUsecase)
+197
View File
@@ -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
}
+70
View File
@@ -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)
}
}
+63
View File
@@ -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
}