feat: live module and jenkins pipeline to harbor deploy
This commit is contained in:
+2
-1
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"kra/internal/config"
|
||||
liveintegration "kra/internal/integration/live"
|
||||
"kra/internal/logging"
|
||||
"kra/internal/server/router"
|
||||
"kra/internal/service/dto"
|
||||
@@ -62,7 +63,7 @@ func buildRuntime(registry *platformtask.Registry, systemRoutes *router.Routes,
|
||||
return systemRoutes
|
||||
}
|
||||
|
||||
func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *systemservice.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client) *kratos.App {
|
||||
func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *systemservice.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client, _ *liveintegration.Manager) *kratos.App {
|
||||
if audit != nil && loggerControl != nil {
|
||||
loggerControl.SetErrorSink(logging.ErrorSinkFunc(func(ctx context.Context, entry logging.ErrorEntry) error {
|
||||
return audit.CreateErrorRequest(ctx, &dto.ErrorRecordRequest{Form: entry.Form, Info: entry.Info, Level: entry.Level, RequestID: entry.RequestID, TraceID: entry.TraceID})
|
||||
|
||||
Generated
+90
-7
@@ -8,20 +8,37 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/go-kratos/kratos/v3"
|
||||
analytics2 "kra/internal/biz/analytics"
|
||||
asset2 "kra/internal/biz/asset"
|
||||
gift2 "kra/internal/biz/gift"
|
||||
integration3 "kra/internal/biz/integration"
|
||||
live2 "kra/internal/biz/live"
|
||||
livechat2 "kra/internal/biz/livechat"
|
||||
liveclient2 "kra/internal/biz/liveclient"
|
||||
payment3 "kra/internal/biz/payment"
|
||||
platformsetting2 "kra/internal/biz/platformsetting"
|
||||
recharge2 "kra/internal/biz/recharge"
|
||||
system2 "kra/internal/biz/system"
|
||||
task2 "kra/internal/biz/task"
|
||||
"kra/internal/config"
|
||||
"kra/internal/data"
|
||||
"kra/internal/data/analytics"
|
||||
"kra/internal/data/asset"
|
||||
"kra/internal/data/gift"
|
||||
"kra/internal/data/integration"
|
||||
"kra/internal/data/live"
|
||||
"kra/internal/data/livechat"
|
||||
"kra/internal/data/liveclient"
|
||||
payment2 "kra/internal/data/payment"
|
||||
"kra/internal/data/platformsetting"
|
||||
"kra/internal/data/recharge"
|
||||
"kra/internal/data/system"
|
||||
"kra/internal/data/task"
|
||||
"kra/internal/initialize"
|
||||
integration2 "kra/internal/integration"
|
||||
"kra/internal/integration/cache"
|
||||
"kra/internal/integration/email"
|
||||
live4 "kra/internal/integration/live"
|
||||
"kra/internal/integration/mq"
|
||||
"kra/internal/integration/payment"
|
||||
"kra/internal/integration/storage"
|
||||
@@ -32,8 +49,16 @@ import (
|
||||
"kra/internal/server"
|
||||
"kra/internal/server/handler"
|
||||
"kra/internal/server/router"
|
||||
analytics3 "kra/internal/service/analytics"
|
||||
asset3 "kra/internal/service/asset"
|
||||
gift3 "kra/internal/service/gift"
|
||||
integration4 "kra/internal/service/integration"
|
||||
live3 "kra/internal/service/live"
|
||||
livechat3 "kra/internal/service/livechat"
|
||||
liveclient3 "kra/internal/service/liveclient"
|
||||
payment4 "kra/internal/service/payment"
|
||||
platformsetting3 "kra/internal/service/platformsetting"
|
||||
recharge3 "kra/internal/service/recharge"
|
||||
system3 "kra/internal/service/system"
|
||||
task3 "kra/internal/service/task"
|
||||
"kra/internal/worker"
|
||||
@@ -170,6 +195,59 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
integrationConfigUsecase := integration3.NewIntegrationConfigUsecase(integrationConfigRepo, connectivityTester)
|
||||
integrationConfigService := integration4.NewIntegrationConfigService(integrationConfigUsecase)
|
||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||
streamRepo := live.NewStreamRepo()
|
||||
streamUsecase := live2.NewStreamUsecase(streamRepo)
|
||||
streamService := live3.NewStreamService(streamUsecase)
|
||||
catalogRepo := live.NewCatalogRepo(dataData)
|
||||
categoryUsecase := live2.NewCategoryUsecase(catalogRepo)
|
||||
roomUsecase := live2.NewRoomUsecase(catalogRepo, streamUsecase)
|
||||
catalogService := live3.NewCatalogService(categoryUsecase, roomUsecase)
|
||||
handlerLive := handler.NewLive(streamService, catalogService)
|
||||
repo := livechat.NewRepo(dataData)
|
||||
usecase := livechat2.NewUsecase(repo)
|
||||
service := livechat3.NewService(usecase)
|
||||
liveChat := handler.NewLiveChat(service)
|
||||
liveclientRepo := liveclient.NewRepo(dataData)
|
||||
liveclientUsecase := liveclient2.NewUsecase(liveclientRepo, securityUsecase, authenticationUsecase, userUsecase)
|
||||
liveclientService := liveclient3.NewService(liveclientUsecase)
|
||||
liveClient := handler.NewLiveClient(liveclientService)
|
||||
giftRepo := gift.NewRepo(dataData)
|
||||
websocketServer, cleanup2, err := websocket.New(runtimeconfigStore)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
manager, cleanup3, err := live4.New(websocketServer, streamUsecase, roomUsecase, usecase, logger)
|
||||
if err != nil {
|
||||
cleanup2()
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
giftUsecase := gift2.NewUsecase(giftRepo, manager)
|
||||
giftService := gift3.NewService(giftUsecase)
|
||||
handlerGift := handler.NewGift(giftService)
|
||||
rechargeRepo := recharge.NewRepo(dataData)
|
||||
rechargeUsecase, err := recharge2.NewUsecase(rechargeRepo, paymentUsecase)
|
||||
if err != nil {
|
||||
cleanup3()
|
||||
cleanup2()
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
rechargeService := recharge3.NewService(rechargeUsecase)
|
||||
handlerRecharge := handler.NewRecharge(rechargeService)
|
||||
assetRepo := asset.NewRepo(dataData)
|
||||
assetUsecase := asset2.NewUsecase(assetRepo)
|
||||
assetService := asset3.NewService(assetUsecase)
|
||||
handlerAsset := handler.NewAsset(assetService)
|
||||
platformsettingRepo := platformsetting.NewRepo(dataData)
|
||||
platformsettingUsecase := platformsetting2.NewUsecase(platformsettingRepo)
|
||||
platformsettingService := platformsetting3.NewService(platformsettingUsecase)
|
||||
platformSetting := handler.NewPlatformSetting(platformsettingService)
|
||||
analyticsRepo := analytics.NewRepo(dataData)
|
||||
analyticsUsecase := analytics2.NewUsecase(analyticsRepo, streamUsecase)
|
||||
analyticsService := analytics3.NewService(analyticsUsecase)
|
||||
handlerAnalytics := handler.NewAnalytics(analyticsService)
|
||||
set := &handler.Set{
|
||||
Authority: authority,
|
||||
Menu: menu,
|
||||
@@ -193,27 +271,32 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
Navigation: navigation,
|
||||
Session: session,
|
||||
IntegrationConfig: integrationConfig,
|
||||
Live: handlerLive,
|
||||
LiveChat: liveChat,
|
||||
LiveClient: liveClient,
|
||||
Gift: handlerGift,
|
||||
Recharge: handlerRecharge,
|
||||
Asset: handlerAsset,
|
||||
PlatformSetting: platformSetting,
|
||||
Analytics: handlerAnalytics,
|
||||
}
|
||||
routes := router.NewRoutes(set)
|
||||
maintenanceRepo := system.NewMaintenanceRepo(dataData)
|
||||
maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo)
|
||||
taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store)
|
||||
routeRegistrar := buildRuntime(registry, routes, taskMethods)
|
||||
websocketServer, cleanup2, err := websocket.New(runtimeconfigStore)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
engine := server.NewGinEngineWithRuntime(store, accessControlService, authService, securityService, auditRecorder, logger, string2, routeRegistrar, websocketServer)
|
||||
httpServer := server.NewGinServer(configServer, engine)
|
||||
mqReloadable, cleanup3, err := mq.New(runtimeconfigStore, logger)
|
||||
mqReloadable, cleanup4, err := mq.New(runtimeconfigStore, logger)
|
||||
if err != nil {
|
||||
cleanup3()
|
||||
cleanup2()
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
app := newApp(logger, httpServer, taskScheduler, auditRecorder, reloadableLogger, mqReloadable)
|
||||
app := newApp(logger, httpServer, taskScheduler, auditRecorder, reloadableLogger, mqReloadable, manager)
|
||||
return app, func() {
|
||||
cleanup4()
|
||||
cleanup3()
|
||||
cleanup2()
|
||||
cleanup()
|
||||
|
||||
+5
-5
@@ -6,13 +6,13 @@ server:
|
||||
data:
|
||||
database:
|
||||
driver: mysql
|
||||
source: ""
|
||||
source: root:Xu950329.@tcp(127.0.0.1:3306)/yzyzlive?charset=utf8mb4&parseTime=True&loc=Local
|
||||
host: 127.0.0.1
|
||||
port: "3306"
|
||||
user: root
|
||||
password: ""
|
||||
name: ""
|
||||
config: timeout=5s&readTimeout=5s&writeTimeout=5s&charset=utf8mb4&parseTime=True&loc=Local
|
||||
password: Xu950329.
|
||||
name: yzyzlive
|
||||
config: charset=utf8mb4&parseTime=True&loc=Local
|
||||
path: ""
|
||||
alias_name: ""
|
||||
disable: false
|
||||
@@ -88,7 +88,7 @@ admin:
|
||||
router_prefix: ""
|
||||
jwt:
|
||||
# Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally.
|
||||
signing_key: ""
|
||||
signing_key: e8e37061-b137-4438-ad48-30f85bf8c68b
|
||||
expires_time: 604800s
|
||||
buffer_time: 86400s
|
||||
issuer: kra
|
||||
|
||||
Vendored
+118
-220
@@ -1,262 +1,160 @@
|
||||
// live.fuhua91.com 部署流水线(基于 Jenkinsfile2 模板改造)
|
||||
// 构建前后端镜像推送 Harbor(harbor.fuhua91.com/live),并 SSH 到宝塔服务器(192.168.2.226)
|
||||
// 执行 /root/deploy.sh 拉取镜像更新容器。
|
||||
//
|
||||
// Jenkins 凭据要求:
|
||||
// - harbor-live-push : Username with password,Harbor 项目 live 的 robot 账号
|
||||
// (robot$live+live / harbor.json 中的 secret)
|
||||
// - deploy-server-ssh-key: SSH Username with private key,可登录 root@192.168.2.226
|
||||
//
|
||||
// 服务器要求: /root/deploy.sh 已就位(见 deploy/deploy.sh),首次已手动跑通
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
|
||||
parameters {
|
||||
choice(
|
||||
name: 'DEPLOY_MODE',
|
||||
choices: ['BUILD_AND_DEPLOY', 'DEPLOY_ONLY'],
|
||||
description: 'BUILD_AND_DEPLOY 构建新镜像并部署;DEPLOY_ONLY 部署 Harbor 中已有的镜像 tag(回滚用)'
|
||||
)
|
||||
string(
|
||||
name: 'IMAGE_TAG',
|
||||
defaultValue: '',
|
||||
description: '要部署的镜像 tag。DEPLOY_ONLY 必填;BUILD_AND_DEPLOY 留空则使用当前 commit'
|
||||
)
|
||||
}
|
||||
|
||||
environment {
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// 项目级参数 —— 基于 kra 模板的新项目只需修改本块。
|
||||
// 这些变量自动 export 到 shell,由 envsubst 白名单渲染进 k8s 清单。
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
APP_NAME = 'kra' // 派生:服务名/Secret名/库名/存储声明/镜像后缀
|
||||
IMAGE_REPO = 'library/kra' // Harbor 中镜像仓库路径
|
||||
REGISTRY = '192.168.200.132'
|
||||
BIN_NAME = 'kratos-admin' // Makefile build 的产物名
|
||||
// 当前单节点部署固定使用单副本,避免首次初始化和 hostPath 数据共享产生竞争。
|
||||
KRA_REPLICAS = '1'
|
||||
// 中间件按环境开关;关闭时对应工作负载缩容到 0,不创建运行 Pod。
|
||||
KRA_DEPLOY_MYSQL = 'true'
|
||||
KRA_DEPLOY_REDIS = 'true'
|
||||
KRA_FRONTEND_NODEPORT = '30080'
|
||||
KRA_BACKEND_NODEPORT = '30081'
|
||||
KRA_MYSQL_IMAGE = 'docker.m.daocloud.io/library/mysql:8.4'
|
||||
KRA_REDIS_IMAGE = 'docker.m.daocloud.io/library/redis:7-alpine'
|
||||
GO_IMAGE = 'docker.m.daocloud.io/golang:1.25'
|
||||
NODE_IMAGE = 'docker.m.daocloud.io/node:20-slim'
|
||||
PNPM_VERSION = '10.15.1'
|
||||
CACHE_DIR = '/var/lib/kra-ci-cache' // Jenkins 宿主机依赖缓存根目录
|
||||
|
||||
// Jenkins 凭据 ID(新项目若沿用同一套凭据可不改)
|
||||
CRED_HARBOR_PUSH = 'harbor-library-push'
|
||||
CRED_KUBECONFIG_DEV = 'kra-rke2-128-kubeconfig'
|
||||
CRED_KUBECONFIG_PROD = 'kra-rke2-129-kubeconfig'
|
||||
CRED_MYSQL_PASSWORD = 'kra-mysql-root-password'
|
||||
CRED_JWT_SIGNING_KEY = 'kra-jwt-signing-key'
|
||||
|
||||
// k8s 清单渲染白名单:只替换列出的变量,避免误伤清单内 shell 变量(如 probe 里的 $MYSQL_ROOT_PASSWORD)
|
||||
K8S_RENDER_VARS = '$KRA_APP $KRA_NAMESPACE $KRA_ENV $KRA_BACKEND_IMAGE $KRA_FRONTEND_IMAGE $KRA_REPLICAS $KRA_DEPLOY_MYSQL $KRA_DEPLOY_REDIS $KRA_MYSQL_REPLICAS $KRA_REDIS_REPLICAS $KRA_MYSQL_WAIT_IMAGE $KRA_FRONTEND_NODEPORT $KRA_BACKEND_NODEPORT $KRA_MYSQL_IMAGE $KRA_REDIS_IMAGE $BIN_NAME'
|
||||
APP_NAME = 'live'
|
||||
REGISTRY_HOST = 'harbor.fuhua91.com'
|
||||
REGISTRY_NAMESPACE = 'live'
|
||||
BACKEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/backend"
|
||||
FRONTEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/frontend"
|
||||
DEPLOY_HOST = 'root@192.168.2.226'
|
||||
BIN_NAME = 'kratos-admin'
|
||||
APP_BASE = 'live' // 前端 vite base 与 nginx 服务名前缀
|
||||
PNPM_VERSION = '10.15.1'
|
||||
DOCKER_BUILDKIT = '1'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Select environment') {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
script {
|
||||
// 分支来源优先级:
|
||||
// 1) env.gitlabBranch —— GitLab Jenkins 集成/webhook 触发时由 gitlab-plugin 注入(push payload 的 ref)
|
||||
// 2) env.BRANCH_NAME —— 多分支流水线场景
|
||||
// 3) JOB_NAME —— 手动触发兜底:名字以 -dev 结尾按 dev 部署,否则默认 main
|
||||
def branch = env.gitlabBranch ?: env.BRANCH_NAME
|
||||
if (!branch) {
|
||||
branch = env.JOB_NAME.endsWith('-dev') ? 'dev' : 'main'
|
||||
}
|
||||
branch = branch.trim().replaceFirst(/^refs\/heads\//, '')
|
||||
// The backend NodePort service has the longest generated name:
|
||||
// <APP_NAME>-backend-nodeport, which must stay within 63 characters.
|
||||
if (!(env.APP_NAME ==~ /^[a-z0-9](?:[a-z0-9-]{0,44}[a-z0-9])?$/)) {
|
||||
error("APP_NAME must be a lowercase Kubernetes name with at most 46 characters: ${env.APP_NAME}")
|
||||
}
|
||||
echo "gitlabBranch='${env.gitlabBranch ?: ''}' BRANCH_NAME='${env.BRANCH_NAME ?: ''}' -> resolved branch='${branch}'"
|
||||
env.KRA_BRANCH = branch
|
||||
if (branch == 'dev') {
|
||||
env.KRA_NAMESPACE = "${env.APP_NAME}-dev"
|
||||
env.KRA_ENV = 'development'
|
||||
env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_DEV
|
||||
} else if (branch == 'main') {
|
||||
env.KRA_NAMESPACE = "${env.APP_NAME}-prod"
|
||||
env.KRA_ENV = 'production'
|
||||
env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_PROD
|
||||
} else {
|
||||
error("Unsupported branch: ${branch}. Only dev and main deploy.")
|
||||
}
|
||||
}
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout triggered commit') {
|
||||
stage('Prepare') {
|
||||
steps {
|
||||
// job 的 SCM branch spec 固定(如 */main),集成触发时按实际推送分支重新检出,
|
||||
// 保证单 job 同时服务 dev/main 两个环境。
|
||||
script {
|
||||
// gitlabAfter 为 push 事件的目标 commit;手动触发时为空。
|
||||
// 注意:不能把 null 赋给 env.*(会被转成字符串 "null"),这里用局部变量。
|
||||
def triggerCommit = env.gitlabAfter?.trim()
|
||||
if (triggerCommit && !(triggerCommit ==~ /[0-9a-fA-F]{40}/)) {
|
||||
error("Invalid GitLab commit SHA: ${triggerCommit}")
|
||||
env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
env.VERSION = sh(script: 'git describe --tags --always --dirty', returnStdout: true).trim()
|
||||
|
||||
if (params.DEPLOY_MODE == 'DEPLOY_ONLY') {
|
||||
if (!params.IMAGE_TAG?.trim()) {
|
||||
error('IMAGE_TAG is required when DEPLOY_MODE=DEPLOY_ONLY')
|
||||
}
|
||||
env.DEPLOY_IMAGE_TAG = params.IMAGE_TAG.trim()
|
||||
} else {
|
||||
env.DEPLOY_IMAGE_TAG = params.IMAGE_TAG?.trim() ? params.IMAGE_TAG.trim() : env.GIT_COMMIT_SHORT
|
||||
}
|
||||
if (triggerCommit && triggerCommit.replace('0', '').isEmpty()) {
|
||||
error('GitLab event points to a deleted branch; refusing to deploy')
|
||||
}
|
||||
if (triggerCommit) {
|
||||
env.KRA_TRIGGER_COMMIT = triggerCommit
|
||||
}
|
||||
checkout([$class: 'GitSCM',
|
||||
branches: [[name: "*/${env.KRA_BRANCH}"]],
|
||||
userRemoteConfigs: [[credentialsId: 'gitlab-root-http', url: 'http://192.168.200.130/root/kra.git']]])
|
||||
if (env.KRA_TRIGGER_COMMIT) {
|
||||
sh "git fetch --no-tags origin ${env.KRA_TRIGGER_COMMIT}"
|
||||
sh "git checkout --detach ${env.KRA_TRIGGER_COMMIT}"
|
||||
}
|
||||
env.KRA_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
if (env.KRA_TRIGGER_COMMIT && env.KRA_COMMIT != env.KRA_TRIGGER_COMMIT) {
|
||||
error("Checked out ${env.KRA_COMMIT}, expected GitLab commit ${env.KRA_TRIGGER_COMMIT}")
|
||||
}
|
||||
echo "building commit ${env.KRA_COMMIT}"
|
||||
|
||||
echo "Deploy mode: ${params.DEPLOY_MODE}"
|
||||
echo "Backend : ${env.BACKEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}"
|
||||
echo "Frontend: ${env.FRONTEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
HOST_WORKSPACE=$(printf '%s' "$WORKSPACE" | sed 's#/var/jenkins_home#/opt/jenkins#')
|
||||
docker run --rm -e GOPROXY=https://goproxy.cn,direct \\
|
||||
-v "$HOST_WORKSPACE:/src" \\
|
||||
-v "$CACHE_DIR/go-mod:/go/pkg/mod" \\
|
||||
-v "$CACHE_DIR/go-build:/root/.cache/go-build" \\
|
||||
-w /src "$GO_IMAGE" sh -c "git config --global --add safe.directory /src && go test ./... && make build"
|
||||
'''
|
||||
sh '''
|
||||
set -eu
|
||||
HOST_WORKSPACE=$(printf '%s' "$WORKSPACE" | sed 's#/var/jenkins_home#/opt/jenkins#')
|
||||
docker run --rm \\
|
||||
-e COREPACK_NPM_REGISTRY=https://registry.npmmirror.com \\
|
||||
-v "$HOST_WORKSPACE/web:/app" \\
|
||||
-v "$CACHE_DIR/pnpm-store:/root/.local/share/pnpm/store" \\
|
||||
-v "$CACHE_DIR/corepack:/root/.cache/node/corepack" \\
|
||||
-w /app "$NODE_IMAGE" bash -lc "corepack enable && corepack prepare pnpm@$PNPM_VERSION --activate && pnpm install --frozen-lockfile && pnpm build"
|
||||
# 前端 Nginx 配置模板渲染(proxy_pass 指向 ${APP_NAME}-backend 服务)。
|
||||
# KRA_APP 必须在此显式导出:envsubst 会把未设置的变量替换为空串。
|
||||
export KRA_APP="$APP_NAME"
|
||||
envsubst '$KRA_APP' < web/.docker-compose/nginx/conf.d/my.conf > web/nginx.k8s.conf
|
||||
go env -w GOPROXY=https://goproxy.cn,direct
|
||||
go test ./...
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build and push images') {
|
||||
stage('Build Frontend') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
env.IMAGE_TAG = "${env.KRA_BRANCH}-${env.KRA_COMMIT.take(12)}"
|
||||
}
|
||||
withCredentials([usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) {
|
||||
// 容器内构建 dist(vite base 由 web/.env.production 的 VITE_APP_BASE=/admin/ 控制),
|
||||
// 并渲染前端 nginx 配置(/api 转发到 live-backend:8000)
|
||||
sh '''
|
||||
docker run --rm \
|
||||
-v "$PWD/web:/app" -w /app \
|
||||
-e COREPACK_NPM_REGISTRY=https://registry.npmmirror.com \
|
||||
-e npm_config_registry=https://registry.npmmirror.com \
|
||||
-v kra-pnpm-store:/root/.local/share/pnpm/store \
|
||||
docker.m.daocloud.io/node:20-slim \
|
||||
bash -lc "corepack enable && corepack prepare pnpm@$PNPM_VERSION --activate && pnpm install --frozen-lockfile && pnpm build"
|
||||
sed "s/\\${KRA_APP}/$APP_BASE/g" web/.docker-compose/nginx/conf.d/my.conf > web/nginx.k8s.conf
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Images') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
sh '''
|
||||
CGO_ENABLED=0 go build -ldflags "-X main.Version=${VERSION}" -o bin/${BIN_NAME} ./cmd
|
||||
docker build \
|
||||
--build-arg BIN_NAME=${BIN_NAME} \
|
||||
--tag ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} \
|
||||
--tag ${BACKEND_IMAGE}:latest \
|
||||
.
|
||||
docker build \
|
||||
--tag ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} \
|
||||
--tag ${FRONTEND_IMAGE}:latest \
|
||||
web
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Push Images') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'harbor-live-push', usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASS')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
umask 077
|
||||
DOCKER_CONFIG_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$DOCKER_CONFIG_DIR"' EXIT
|
||||
echo "$HARBOR_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||
docker --config "$DOCKER_CONFIG_DIR" build --build-arg BIN_NAME="$BIN_NAME" -t "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" .
|
||||
docker --config "$DOCKER_CONFIG_DIR" build -t "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" web
|
||||
docker --config "$DOCKER_CONFIG_DIR" push "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG"
|
||||
docker --config "$DOCKER_CONFIG_DIR" push "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG"
|
||||
docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||
echo "${HARBOR_PASS}" | docker login "${REGISTRY_HOST}" -u "${HARBOR_USER}" --password-stdin
|
||||
docker push ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG}
|
||||
docker push ${BACKEND_IMAGE}:latest
|
||||
docker push ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG}
|
||||
docker push ${FRONTEND_IMAGE}:latest
|
||||
docker logout "${REGISTRY_HOST}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy to Rancher cluster') {
|
||||
stage('Deploy') {
|
||||
steps {
|
||||
script {
|
||||
if (!(env.KRA_DEPLOY_MYSQL in ['true', 'false'])) {
|
||||
error("KRA_DEPLOY_MYSQL must be true or false: ${env.KRA_DEPLOY_MYSQL}")
|
||||
}
|
||||
if (!(env.KRA_DEPLOY_REDIS in ['true', 'false'])) {
|
||||
error("KRA_DEPLOY_REDIS must be true or false: ${env.KRA_DEPLOY_REDIS}")
|
||||
}
|
||||
def credentialBindings = [
|
||||
file(credentialsId: "${env.KUBECONFIG_CREDENTIALS}", variable: 'KUBECONFIG_FILE'),
|
||||
string(credentialsId: env.CRED_JWT_SIGNING_KEY, variable: 'KRA_JWT_SIGNING_KEY'),
|
||||
usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')
|
||||
]
|
||||
if (env.KRA_DEPLOY_MYSQL == 'true') {
|
||||
credentialBindings << string(credentialsId: env.CRED_MYSQL_PASSWORD, variable: 'KRA_MYSQL_ROOT_PASSWORD')
|
||||
}
|
||||
withCredentials(credentialBindings) {
|
||||
sh '''
|
||||
set -eu
|
||||
export KUBECONFIG="$KUBECONFIG_FILE"
|
||||
umask 077
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
kubectl create namespace "$KRA_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
|
||||
DOCKER_CONFIG_DIR="$TMP_DIR/docker"
|
||||
mkdir -p "$DOCKER_CONFIG_DIR"
|
||||
printf '%s' "$HARBOR_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||
kubectl -n "$KRA_NAMESPACE" create secret generic harbor-registry \\
|
||||
--type=kubernetes.io/dockerconfigjson \\
|
||||
--from-file=.dockerconfigjson="$DOCKER_CONFIG_DIR/config.json" \\
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||
if [ "$KRA_DEPLOY_MYSQL" = 'true' ]; then
|
||||
test -n "$KRA_MYSQL_ROOT_PASSWORD" || { echo 'MySQL root password credential is empty' >&2; exit 1; }
|
||||
printf '%s' "$KRA_MYSQL_ROOT_PASSWORD" > "$TMP_DIR/mysql-root-password"
|
||||
kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-runtime" \\
|
||||
--from-file=mysql-root-password="$TMP_DIR/mysql-root-password" \\
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
fi
|
||||
test -n "$KRA_JWT_SIGNING_KEY" || { echo 'JWT signing key credential is empty' >&2; exit 1; }
|
||||
if [ "$(printf '%s' "$KRA_JWT_SIGNING_KEY" | wc -l)" -ne 0 ]; then
|
||||
echo 'JWT signing key credential must be a single line' >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$KRA_JWT_SIGNING_KEY" in
|
||||
*"'"*) echo "JWT signing key credential must not contain a single quote" >&2; exit 1 ;;
|
||||
esac
|
||||
CONFIG_TEMPLATE="$TMP_DIR/config.yaml"
|
||||
envsubst '$APP_NAME $KRA_ENV $KRA_JWT_SIGNING_KEY' < deploy/k8s/config.example.yaml > "$CONFIG_TEMPLATE"
|
||||
kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-config-template" \\
|
||||
--from-file=config.yaml="$CONFIG_TEMPLATE" \\
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl -n "$KRA_NAMESPACE" delete configmap "${APP_NAME}-config-template" --ignore-not-found
|
||||
export KRA_APP="$APP_NAME"
|
||||
export KRA_BACKEND_IMAGE="$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG"
|
||||
export KRA_FRONTEND_IMAGE="$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG"
|
||||
case "$KRA_DEPLOY_MYSQL" in
|
||||
true) export KRA_MYSQL_REPLICAS=1; export KRA_MYSQL_WAIT_IMAGE="$KRA_MYSQL_IMAGE" ;;
|
||||
false) export KRA_MYSQL_REPLICAS=0; export KRA_MYSQL_WAIT_IMAGE="$KRA_BACKEND_IMAGE" ;;
|
||||
*) echo 'KRA_DEPLOY_MYSQL must be true or false' >&2; exit 1 ;;
|
||||
esac
|
||||
case "$KRA_DEPLOY_REDIS" in
|
||||
true) export KRA_REDIS_REPLICAS=1 ;;
|
||||
false) export KRA_REDIS_REPLICAS=0 ;;
|
||||
*) echo 'KRA_DEPLOY_REDIS must be true or false' >&2; exit 1 ;;
|
||||
esac
|
||||
RENDERED_MANIFEST="$TMP_DIR/manifest.yaml"
|
||||
envsubst "$K8S_RENDER_VARS" < deploy/k8s/kra.yaml > "$RENDERED_MANIFEST"
|
||||
# Groovy 三单引号字符串内反斜杠仍是转义前缀:
|
||||
# 本行正则里的反斜杠序列必须双写,shell 才能收到 grep -E 需要的单反斜杠模式。
|
||||
if grep -nE '\\$\\{[A-Z_][A-Z0-9_]*\\}' "$RENDERED_MANIFEST"; then
|
||||
echo 'Rendered Kubernetes manifest contains unresolved template variables' >&2
|
||||
exit 1
|
||||
fi
|
||||
MYSQL_SERVICE_IP=$(kubectl -n "$KRA_NAMESPACE" get service "${APP_NAME}-mysql" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)
|
||||
if [ -n "$MYSQL_SERVICE_IP" ] && [ "$MYSQL_SERVICE_IP" != 'None' ]; then
|
||||
echo "${APP_NAME}-mysql already exists as a ClusterIP Service; delete it during a maintenance window before switching to headless mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
kubectl apply --dry-run=server -f "$RENDERED_MANIFEST"
|
||||
kubectl apply -f "$RENDERED_MANIFEST"
|
||||
if [ "$KRA_DEPLOY_MYSQL" = 'true' ]; then
|
||||
RUNTIME_SECRET_VERSION=$(kubectl -n "$KRA_NAMESPACE" get secret "${APP_NAME}-runtime" -o jsonpath='{.metadata.resourceVersion}')
|
||||
PATCH_FILE="$TMP_DIR/mysql-patch.json"
|
||||
printf '{"spec":{"template":{"metadata":{"annotations":{"kra.dev/runtime-secret-version":"%s"}}}}}\n' \\
|
||||
"$RUNTIME_SECRET_VERSION" > "$PATCH_FILE"
|
||||
kubectl -n "$KRA_NAMESPACE" patch statefulset "${APP_NAME}-mysql" --type=merge \\
|
||||
--patch-file="$PATCH_FILE"
|
||||
kubectl -n "$KRA_NAMESPACE" rollout status statefulset/${APP_NAME}-mysql --timeout=10m
|
||||
fi
|
||||
if [ "$KRA_DEPLOY_REDIS" = 'true' ]; then
|
||||
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-redis --timeout=10m
|
||||
fi
|
||||
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-backend --timeout=10m
|
||||
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-frontend --timeout=10m
|
||||
'''
|
||||
}
|
||||
sshagent(credentials: ['deploy-server-ssh-key']) {
|
||||
sh '''
|
||||
ssh -o StrictHostKeyChecking=no ${DEPLOY_HOST} "bash /root/deploy.sh ${DEPLOY_IMAGE_TAG}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "Deployed ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} / ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
|
||||
parameters {
|
||||
choice(
|
||||
name: 'DEPLOY_MODE',
|
||||
choices: ['BUILD_AND_DEPLOY', 'DEPLOY_ONLY'],
|
||||
description: 'BUILD_AND_DEPLOY builds and deploys a new image; DEPLOY_ONLY deploys an existing Harbor image tag'
|
||||
)
|
||||
string(
|
||||
name: 'IMAGE_TAG',
|
||||
defaultValue: '',
|
||||
description: 'Image tag to deploy. Required for DEPLOY_ONLY; empty BUILD_AND_DEPLOY uses current commit'
|
||||
)
|
||||
}
|
||||
|
||||
environment {
|
||||
APP_NAME = 'big-party-rpc-script'
|
||||
REGISTRY_HOST = '192.168.2.160:8088'
|
||||
REGISTRY_NAMESPACE = 'zhoubao'
|
||||
IMAGE_REPOSITORY = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${APP_NAME}"
|
||||
DEPLOY_HOST = 'root@192.168.2.226'
|
||||
DEPLOY_DIR = "/opt/${APP_NAME}"
|
||||
GITLAB_PROXY_HOST = '192.168.2.160'
|
||||
DOCKER_BUILDKIT = '1'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Prepare') {
|
||||
steps {
|
||||
script {
|
||||
env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
env.VERSION = sh(script: 'git describe --tags --always --dirty', returnStdout: true).trim()
|
||||
|
||||
if (params.DEPLOY_MODE == 'DEPLOY_ONLY') {
|
||||
if (!params.IMAGE_TAG?.trim()) {
|
||||
error('IMAGE_TAG is required when DEPLOY_MODE=DEPLOY_ONLY')
|
||||
}
|
||||
env.DEPLOY_IMAGE_TAG = params.IMAGE_TAG.trim()
|
||||
} else {
|
||||
env.DEPLOY_IMAGE_TAG = params.IMAGE_TAG?.trim() ? params.IMAGE_TAG.trim() : env.GIT_COMMIT_SHORT
|
||||
}
|
||||
|
||||
echo "Deploy mode: ${params.DEPLOY_MODE}"
|
||||
echo "Image: ${env.IMAGE_REPOSITORY}:${env.DEPLOY_IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
withCredentials([sshUserPrivateKey(credentialsId: 'gitlab-go-deps-ssh-key', keyFileVariable: 'GIT_SSH_KEY')]) {
|
||||
sh '''
|
||||
git config --global url."git@gitlab.fuhua91.com:".insteadOf "https://gitlab.fuhua91.com/"
|
||||
git config --global url."git@gitlab.fuhua91.com:".insteadOf "http://gitlab.fuhua91.com/"
|
||||
export GIT_SSH_COMMAND="ssh -i ${GIT_SSH_KEY} -o StrictHostKeyChecking=no"
|
||||
go env -w GOPROXY=https://goproxy.cn,direct
|
||||
go env -w GOPRIVATE=gitlab.fuhua91.com
|
||||
go env -w GOINSECURE=gitlab.fuhua91.com
|
||||
go test ./...
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Image') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
withCredentials([sshUserPrivateKey(credentialsId: 'gitlab-go-deps-ssh-key', keyFileVariable: 'GIT_SSH_KEY')]) {
|
||||
sh '''
|
||||
docker build \
|
||||
--file deploy/Dockerfile \
|
||||
--add-host gitlab.fuhua91.com:${GITLAB_PROXY_HOST} \
|
||||
--secret id=git_ssh_key,src=${GIT_SSH_KEY} \
|
||||
--build-arg VERSION=${VERSION} \
|
||||
--tag ${IMAGE_REPOSITORY}:${DEPLOY_IMAGE_TAG} \
|
||||
--tag ${IMAGE_REPOSITORY}:latest \
|
||||
.
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Push Image') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'docker-registry', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASS')]) {
|
||||
sh '''
|
||||
echo "${REGISTRY_PASS}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin
|
||||
docker push ${IMAGE_REPOSITORY}:${DEPLOY_IMAGE_TAG}
|
||||
docker push ${IMAGE_REPOSITORY}:latest
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'docker-registry', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASS')]) {
|
||||
sshagent(credentials: ['deploy-server-ssh-key']) {
|
||||
sh '''
|
||||
ssh -o StrictHostKeyChecking=no ${DEPLOY_HOST} "mkdir -p ${DEPLOY_DIR}/deploy ${DEPLOY_DIR}/configs ${DEPLOY_DIR}/logs"
|
||||
scp -O -o StrictHostKeyChecking=no deploy/docker-compose.yaml ${DEPLOY_HOST}:${DEPLOY_DIR}/deploy/docker-compose.yaml
|
||||
scp -O -o StrictHostKeyChecking=no configs/config.yaml ${DEPLOY_HOST}:${DEPLOY_DIR}/configs/config.yaml
|
||||
printf '%s' "${REGISTRY_PASS}" | ssh -o StrictHostKeyChecking=no ${DEPLOY_HOST} "docker login '${REGISTRY_HOST}' -u '${REGISTRY_USER}' --password-stdin"
|
||||
ssh -o StrictHostKeyChecking=no ${DEPLOY_HOST} "
|
||||
cd ${DEPLOY_DIR}/deploy &&
|
||||
printf '%s\\n' \
|
||||
'IMAGE_REPOSITORY=${IMAGE_REPOSITORY}' \
|
||||
'IMAGE_TAG=${DEPLOY_IMAGE_TAG}' \
|
||||
'HTTP_PORT=20200' \
|
||||
'GRPC_PORT=20201' \
|
||||
'CONFIG_FILE=../configs/config.yaml' \
|
||||
'LOG_DIR=../logs' > .env &&
|
||||
docker-compose pull &&
|
||||
docker-compose up -d &&
|
||||
docker image prune -f
|
||||
"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "Deployed ${IMAGE_REPOSITORY}:${DEPLOY_IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# live.fuhua91.com 宝塔站点配置
|
||||
# 路径规划:
|
||||
# / -> live 用户端前端(静态目录,或替换为反代)
|
||||
# /admin/ -> 管理后台(frontend 容器 9501,nginx 去前缀转发)
|
||||
# /api -> 后端 API/WebSocket(backend 容器 9502)
|
||||
# 注意: 本文件顶部的 map 位于 http 上下文(宝塔 include 位置),合法。
|
||||
|
||||
map $http_upgrade $live_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2 ;
|
||||
server_name live.fuhua91.com;
|
||||
root /www/wwwroot/live.fuhua91.com;
|
||||
index index.html;
|
||||
#CERT-APPLY-CHECK--START
|
||||
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除
|
||||
include /www/server/panel/vhost/nginx/well-known/live.fuhua91.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
include /www/server/panel/vhost/nginx/extension/live.fuhua91.com/*.conf;
|
||||
|
||||
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
|
||||
#error_page 404/404.html;
|
||||
ssl_certificate /www/server/panel/vhost/cert/live.fuhua91.com/fullchain.pem;
|
||||
ssl_certificate_key /www/server/panel/vhost/cert/live.fuhua91.com/privkey.pem;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
|
||||
#SSL-END
|
||||
|
||||
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
|
||||
error_page 404 /404.html;
|
||||
#error_page 502 /502.html;
|
||||
#ERROR-PAGE-END
|
||||
|
||||
# 大文件上传(视频分片走 /api)
|
||||
client_max_body_size 1024m;
|
||||
|
||||
# 一键申请SSL证书验证目录: 直接读本地磁盘,不进容器
|
||||
location ^~ /.well-known/ {
|
||||
root /www/wwwroot/live.fuhua91.com;
|
||||
allow all;
|
||||
}
|
||||
|
||||
# 禁止访问的敏感文件
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 禁止访问的敏感目录
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 后端 API / WebSocket(容器内后端无 /api 前缀,proxy_pass 尾斜杠自动去掉)
|
||||
location ^~ /api {
|
||||
proxy_pass http://127.0.0.1:9502;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $live_connection_upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
# 管理后台(frontend 容器;rewrite 去掉 /admin 前缀,前端已按 /admin/ base 构建)
|
||||
location = /admin {
|
||||
return 301 /admin/;
|
||||
}
|
||||
location ^~ /admin/ {
|
||||
rewrite ^/admin/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:9501;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# live 用户端前端(根路径)
|
||||
# 方式一: 静态文件放 /www/wwwroot/live.fuhua91.com/live-web 后使用当前配置
|
||||
# 方式二: 若 live 前端也是 docker 容器,把下面两行换成:
|
||||
# proxy_pass http://127.0.0.1:<live前端端口>;
|
||||
# proxy_set_header Host $http_host; 以及上面 /admin/ 块中的其余 proxy_set_header
|
||||
location / {
|
||||
root /www/wwwroot/live.fuhua91.com/live-web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
#禁止在证书验证目录放入敏感文件
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
|
||||
include /www/server/panel/vhost/rewrite/live.fuhua91.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
access_log /www/wwwlogs/live.fuhua91.com.log;
|
||||
error_log /www/wwwlogs/live.fuhua91.com.error.log;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# live.fuhua91.com 一键部署脚本
|
||||
#
|
||||
# 用法:
|
||||
# ./deploy.sh 拉取 latest 最新镜像并重启
|
||||
# ./deploy.sh v1.0.0 部署指定版本
|
||||
#
|
||||
# 建议: 放到 /root/deploy.sh,执行 chmod 700 /root/deploy.sh
|
||||
# 依赖: 服务器已安装 docker,且能访问 harbor.fuhua91.com
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- 可按需调整的参数 ----------
|
||||
REGISTRY="harbor.fuhua91.com"
|
||||
PROJECT="live"
|
||||
# Harbor 机器人账号(也可通过环境变量 HARBOR_USER / HARBOR_PASSWORD 覆盖)
|
||||
HARBOR_USER_DEFAULT='robot$live+live'
|
||||
HARBOR_PASSWORD_DEFAULT='R4p6ieacIogXE779eL1HXhCpA4bx4SA7'
|
||||
|
||||
BASE_DIR="/www/wwwroot/live.fuhua91.com"
|
||||
CONFIG_DIR="$BASE_DIR/config" # 后端配置目录 -> 容器内 /data/conf
|
||||
LOG_DIR="$BASE_DIR/log" # 后端日志目录 -> 容器内 /app/logs
|
||||
UPLOADS_DIR="$BASE_DIR/uploads" # 上传文件目录 -> 容器内 /app/uploads
|
||||
|
||||
FRONTEND_PORT=9501 # 前端 nginx 容器
|
||||
BACKEND_PORT=9502 # 后端 API/WebSocket 容器
|
||||
NETWORK="live-net"
|
||||
BACKEND_NAME="live-backend"
|
||||
FRONTEND_NAME="live-frontend"
|
||||
# --------------------------------------
|
||||
|
||||
TAG="${1:-latest}"
|
||||
BACKEND_IMAGE="$REGISTRY/$PROJECT/backend:$TAG"
|
||||
FRONTEND_IMAGE="$REGISTRY/$PROJECT/frontend:$TAG"
|
||||
|
||||
echo "==> 部署版本: $TAG"
|
||||
|
||||
# 1. 准备挂载目录
|
||||
mkdir -p "$CONFIG_DIR" "$LOG_DIR" "$UPLOADS_DIR"
|
||||
|
||||
# 2. 首次运行检查配置文件
|
||||
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
|
||||
echo "错误: 缺少 $CONFIG_DIR/config.yaml"
|
||||
echo "请先将项目 configs/config.yaml 复制到该路径,"
|
||||
echo "并修改其中的 MySQL / Redis 等连接信息后重新执行本脚本。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. 登录 Harbor(优先使用环境变量 HARBOR_USER / HARBOR_PASSWORD)
|
||||
HARBOR_USER="${HARBOR_USER:-$HARBOR_USER_DEFAULT}"
|
||||
HARBOR_PASSWORD="${HARBOR_PASSWORD:-$HARBOR_PASSWORD_DEFAULT}"
|
||||
echo "==> 登录 $REGISTRY"
|
||||
echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||
|
||||
# 4. 拉取最新镜像
|
||||
echo "==> 拉取镜像"
|
||||
docker pull "$BACKEND_IMAGE"
|
||||
docker pull "$FRONTEND_IMAGE"
|
||||
|
||||
# 5. 重建容器
|
||||
echo "==> 重启容器"
|
||||
docker network create "$NETWORK" >/dev/null 2>&1 || true
|
||||
docker rm -f "$BACKEND_NAME" "$FRONTEND_NAME" 2>/dev/null || true
|
||||
|
||||
# 容器内进程以 uid 1000 (kra) 运行,日志/上传目录需要写权限
|
||||
chown -R 1000:1000 "$LOG_DIR" "$UPLOADS_DIR" 2>/dev/null || \
|
||||
chmod -R 777 "$LOG_DIR" "$UPLOADS_DIR"
|
||||
|
||||
# 后端: 配置 /data/conf,日志 /app/logs,上传 /app/uploads
|
||||
docker run -d --name "$BACKEND_NAME" \
|
||||
--restart always \
|
||||
--network "$NETWORK" \
|
||||
-p "$BACKEND_PORT:8000" \
|
||||
-v "$CONFIG_DIR:/data/conf" \
|
||||
-v "$LOG_DIR:/app/logs" \
|
||||
-v "$UPLOADS_DIR:/app/uploads" \
|
||||
"$BACKEND_IMAGE"
|
||||
|
||||
# 前端: nginx 静态站点,容器内已配置 /api -> live-backend:8000
|
||||
docker run -d --name "$FRONTEND_NAME" \
|
||||
--restart always \
|
||||
--network "$NETWORK" \
|
||||
-p "$FRONTEND_PORT:80" \
|
||||
"$FRONTEND_IMAGE"
|
||||
|
||||
docker logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||
|
||||
echo "==> 部署完成"
|
||||
docker ps --filter "name=$BACKEND_NAME" --filter "name=$FRONTEND_NAME"
|
||||
echo ""
|
||||
echo "前端容器: http://<服务器IP>:$FRONTEND_PORT"
|
||||
echo "后端容器: http://<服务器IP>:$BACKEND_PORT"
|
||||
echo "提示: 网站正常访问走宝塔 nginx 反代(80/443),9501/9502 为直连调试端口,记得放行防火墙。"
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# Go WebRTC Live
|
||||
|
||||
直播功能使用 Pion WebRTC,在现有 GVA WebSocket 和 JWT 用户体系上实现单主播、多观众的 SFU 转发。
|
||||
|
||||
## WebSocket
|
||||
|
||||
连接现有 WebSocket 地址(默认 `/ws`),握手必须携带 GVA JWT:
|
||||
|
||||
```text
|
||||
wss://host/ws?token=<x-token>
|
||||
```
|
||||
|
||||
连接后发送:
|
||||
|
||||
```json
|
||||
{"type":"Join","payload":{"role":"publisher","streamId":"room-1"}}
|
||||
```
|
||||
|
||||
角色支持 `publisher`、`subscriber`、`observer`。主播和观众完成 Join 后,浏览器发送 SDP offer:
|
||||
|
||||
```json
|
||||
{"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}}
|
||||
```
|
||||
|
||||
服务端返回 `Answer`。主播使用发送方向的 transceiver,观众使用接收方向的 transceiver。媒体轨道由 Pion 在服务端复制给同一房间的所有观众。
|
||||
|
||||
其他消息:
|
||||
|
||||
- `Heartbeat` / `HeartbeatAck`
|
||||
- `PublisherStatus`
|
||||
- `NewProducer`
|
||||
- `ProducerClosed`
|
||||
- `NewSubscriber`
|
||||
- `SubscriberLeft`
|
||||
- `StreamListUpdate`
|
||||
- `Kicked`
|
||||
- `ChatHistory`:加入房间后返回最近 100 条聊天和礼物消息
|
||||
- `ChatMessage`:普通聊天广播
|
||||
- `GiftMessage`:送礼成功后的礼物广播
|
||||
|
||||
发送聊天消息:
|
||||
|
||||
```json
|
||||
{"type":"ChatSend","payload":{"content":"大家好"}}
|
||||
```
|
||||
|
||||
聊天只允许已经以主播或观众身份加入该房间的登录用户发送,内容最多 50 个字符。
|
||||
|
||||
主播和观众 45 秒没有心跳会被清理,直播房间最多 20 个;同一用户在同一房间重复观看时,旧连接会收到 `Kicked`。
|
||||
|
||||
## HTTP
|
||||
|
||||
公开列表接口:
|
||||
|
||||
```text
|
||||
GET /live/streams
|
||||
GET /live/streams/:streamId
|
||||
GET /api/streams # 旧客户端兼容别名
|
||||
GET /live/categories
|
||||
GET /live/rooms?page=1&pageSize=20&categoryId=1&status=live
|
||||
GET /live/rooms/:id
|
||||
GET /live/rooms/:id/messages?limit=100
|
||||
GET /live/gifts
|
||||
```
|
||||
|
||||
PC 客户端账号接口:
|
||||
|
||||
```text
|
||||
POST /live/client/auth/register
|
||||
POST /live/client/auth/login
|
||||
GET /live/client/me
|
||||
PUT /live/client/me
|
||||
PUT /live/client/me/password
|
||||
```
|
||||
|
||||
客户端账号使用系统的 `sys_users` 表,注册时创建普通直播角色并初始化 100 钻石。注册和修改密码都会返回新的 JWT 登录态。
|
||||
|
||||
登录后可调用:
|
||||
|
||||
```text
|
||||
POST /live/rooms/:id/gifts
|
||||
GET /live/recharge/plans
|
||||
POST /live/recharge/orders
|
||||
```
|
||||
|
||||
创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识:
|
||||
|
||||
```json
|
||||
{
|
||||
"planId": 1,
|
||||
"provider": "wechat-v3",
|
||||
"notifyUrl": "https://example.com/api/payment/callback/wechat-v3",
|
||||
"returnUrl": "https://example.com/recharge/result"
|
||||
}
|
||||
```
|
||||
|
||||
充值档位、用户和钻石数量保存在直播业务订单中;渠道交易号、支付状态、回调、退款状态和事件流水统一保存在 KRA 的 `pay_orders` / `pay_events`。支付确认后由通用支付发货流程幂等增加钻石余额。
|
||||
|
||||
礼物数量必须使用礼物接口返回的 `tiers`,扣款、礼物流水和钱包余额在同一事务内完成;成功后通过 `GiftMessage` 广播给房间内在线用户。
|
||||
|
||||
全站设置继续保存在 `live_settings`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。
|
||||
|
||||
直播状态和在线连接是进程内状态,行为与旧项目一致;服务重启后房间自动清空。
|
||||
|
||||
## 网络配置
|
||||
|
||||
默认使用 Pion 主机候选。生产环境可以配置:
|
||||
|
||||
```text
|
||||
KRA_LIVE_STUN_URL=stun:stun.example.com:3478
|
||||
KRA_LIVE_UDP_MIN_PORT=40000
|
||||
KRA_LIVE_UDP_MAX_PORT=49999
|
||||
```
|
||||
|
||||
配置 UDP 端口范围后,需要在防火墙或容器网络中放行对应端口。
|
||||
@@ -28,6 +28,7 @@ require (
|
||||
github.com/minio/minio-go/v7 v7.0.92
|
||||
github.com/mojocn/base64Captcha v1.3.8
|
||||
github.com/olahol/melody v1.4.0
|
||||
github.com/pion/webrtc/v4 v4.2.20
|
||||
github.com/qiniu/go-sdk/v7 v7.25.2
|
||||
github.com/rabbitmq/amqp091-go v1.14.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
@@ -145,6 +146,21 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||
github.com/pion/datachannel v1.6.2 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.8 // indirect
|
||||
github.com/pion/ice/v4 v4.4.2 // indirect
|
||||
github.com/pion/interceptor v0.1.48 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.2.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.17 // indirect
|
||||
github.com/pion/rtp v1.10.5 // indirect
|
||||
github.com/pion/sctp v1.11.1 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.19 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.13 // indirect
|
||||
github.com/pion/stun/v4 v4.0.0 // indirect
|
||||
github.com/pion/transport/v4 v4.1.0 // indirect
|
||||
github.com/pion/turn/v5 v5.1.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
@@ -163,6 +179,7 @@ require (
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
@@ -175,7 +192,7 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/image v0.23.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
|
||||
@@ -321,11 +321,42 @@ github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1Gsh
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
|
||||
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
|
||||
github.com/pion/dtls/v3 v3.1.8 h1:aLcgjZqzrYn5AbjSds4LvK2WI5VzJc1PencExyDjYis=
|
||||
github.com/pion/dtls/v3 v3.1.8/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
|
||||
github.com/pion/ice/v4 v4.4.2 h1:asS17nbHJrzlVQl8fiSJaipxrxSY3Dq6DWgmB+0VwpI=
|
||||
github.com/pion/ice/v4 v4.4.2/go.mod h1:YZgNFOyJWXpLpLj0mb4ccqNAWRo9C2RtqKwuVTEz0Sc=
|
||||
github.com/pion/interceptor v0.1.48 h1:FF4gZ6Yh+N75gKMYpC7rYR8DdkiMBFtA7V2OBUKo7XQ=
|
||||
github.com/pion/interceptor v0.1.48/go.mod h1:5mg/N5xXMAa4codCUdrJYY9I1y4tVQhlpd7rfwAJpvI=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.2.0 h1:AlAZ9MTUKtWgO+4itk35JdNak4sk5k7G/X4xnIBWHyA=
|
||||
github.com/pion/mdns/v2 v2.2.0/go.mod h1:IJddx58QMlojqhQYjHcOUmvuBQ5MnLNetkb80VMvk2Y=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
|
||||
github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
|
||||
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
|
||||
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA=
|
||||
github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
|
||||
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
|
||||
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
|
||||
github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q=
|
||||
github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY=
|
||||
github.com/pion/stun/v4 v4.0.0 h1:UuQy2q6iZR4EnMl/+G8kAtaWhf7jx/u8ZyN9oD4+YEQ=
|
||||
github.com/pion/stun/v4 v4.0.0/go.mod h1:JAojPsPtDH4iPeNQb7kvxBdzypmkWAROxy0coApKH2E=
|
||||
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
|
||||
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v5 v5.1.0 h1:OSzLub7q4GssG1P4BEVrz39MnnJlxLy1LOvEkP9f46o=
|
||||
github.com/pion/turn/v5 v5.1.0/go.mod h1:6HJQO7UAe7pEPMrTtBrmj+tTfp+Ai8KAV2+GXHFi1nQ=
|
||||
github.com/pion/webrtc/v4 v4.2.20 h1:NYiNhBTFArA8aoP18a30y4LN0dyqSrF65HxU7KnhNOo=
|
||||
github.com/pion/webrtc/v4 v4.2.20/go.mod h1:aLGXbekuN0tHOu7IPX1o9Y/uN29Ovfjaaz4bL2P9ND8=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
@@ -388,8 +419,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
@@ -414,6 +445,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
@@ -455,8 +488,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gorm.io/gorm"
|
||||
biz "kra/internal/biz/analytics"
|
||||
"kra/internal/data/ledger"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
type repo struct{ data Provider }
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
func (r *repo) Dashboard(ctx context.Context, from, to time.Time) (*biz.Dashboard, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
out := &biz.Dashboard{DiamondRateCents: 10}
|
||||
if err := db.Table("live_rooms").Where("deleted_at IS NULL").Count(&out.RoomTotal).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var set ledger.SettingPO
|
||||
if err := db.Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&set).Error; err == nil {
|
||||
if v, e := strconv.ParseInt(set.Value, 10, 64); e == nil {
|
||||
out.DiamondRateCents = v
|
||||
}
|
||||
}
|
||||
var f struct{ RechargeCents, PaidOrderCount int64 }
|
||||
if err := db.Model(&ledger.RechargeOrderPO{}).Select("COALESCE(SUM(amount_cents),0) AS recharge_cents,COUNT(*) AS paid_order_count").Where("credited_at IS NOT NULL AND credited_at BETWEEN ? AND ?", from, to).Scan(&f).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.RechargeCents, out.PaidOrderCount = f.RechargeCents, f.PaidOrderCount
|
||||
var g struct{ Diamonds, Count int64 }
|
||||
if err := db.Model(&ledger.GiftSendPO{}).Select("COALESCE(SUM(diamond_total),0) AS diamonds,COUNT(*) AS count").Where("created_at BETWEEN ? AND ?", from, to).Scan(&g).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.GiftDiamonds, out.GiftSendCount = g.Diamonds, g.Count
|
||||
start := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, to.Location()).AddDate(0, 0, -6)
|
||||
var points []struct {
|
||||
Date string
|
||||
AmountCents int64
|
||||
}
|
||||
if err := db.Model(&ledger.RechargeOrderPO{}).Select("DATE(credited_at) AS date,COALESCE(SUM(amount_cents),0) AS amount_cents").Where("credited_at IS NOT NULL AND credited_at >= ?", start).Group("DATE(credited_at)").Order("DATE(credited_at)").Scan(&points).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]int64{}
|
||||
for _, p := range points {
|
||||
m[p.Date] = p.AmountCents
|
||||
}
|
||||
for i := 0; i < 7; i++ {
|
||||
d := start.AddDate(0, 0, i).Format("2006-01-02")
|
||||
out.RechargeTrend = append(out.RechargeTrend, &biz.TrendPoint{Date: d, AmountCents: m[d]})
|
||||
}
|
||||
var ranks []struct {
|
||||
RoomID uint
|
||||
RoomName, AnchorName string
|
||||
Diamonds int64
|
||||
}
|
||||
if err := db.Model(&ledger.GiftSendPO{}).Select("COALESCE(room_id,0) AS room_id,room_name,anchor_name,SUM(diamond_total) AS diamonds").Where("created_at BETWEEN ? AND ?", from, to).Group("room_id,room_name,anchor_name").Order("diamonds DESC").Limit(5).Scan(&ranks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range ranks {
|
||||
out.GiftRanking = append(out.GiftRanking, &biz.GiftRank{RoomID: x.RoomID, RoomName: x.RoomName, AnchorName: x.AnchorName, Diamonds: x.Diamonds})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package analytics
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewRepo)
|
||||
@@ -0,0 +1,144 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
biz "kra/internal/biz/asset"
|
||||
"kra/internal/data/ledger"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
type repo struct{ data Provider }
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) }
|
||||
|
||||
type row struct {
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
Username string
|
||||
Nickname string
|
||||
Avatar string
|
||||
Enable int
|
||||
CreatedAt time.Time
|
||||
DiamondBalance, TotalRechargeCents, TotalSpentDiamonds int64
|
||||
LastLoginAt *time.Time
|
||||
}
|
||||
|
||||
func query(db *gorm.DB, f biz.Filter) *gorm.DB {
|
||||
q := db.Table("sys_users").Joins("LEFT JOIN live_wallets ON live_wallets.user_id = sys_users.id").Where("sys_users.deleted_at IS NULL")
|
||||
if f.Keyword != "" {
|
||||
v := "%" + f.Keyword + "%"
|
||||
q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v)
|
||||
}
|
||||
if f.UserID > 0 {
|
||||
q = q.Where("sys_users.id = ?", f.UserID)
|
||||
}
|
||||
if f.Enabled != nil {
|
||||
if *f.Enabled {
|
||||
q = q.Where("sys_users.enable = 1")
|
||||
} else {
|
||||
q = q.Where("sys_users.enable <> 1")
|
||||
}
|
||||
}
|
||||
if len(f.CreatedAt) == 2 {
|
||||
q = q.Where("sys_users.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1])
|
||||
}
|
||||
return q
|
||||
}
|
||||
func (r *repo) List(ctx context.Context, f biz.Filter, o biz.ListOptions) ([]*biz.User, int64, error) {
|
||||
q := query(r.db(ctx), f)
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []row
|
||||
sel := "sys_users.id AS user_id,sys_users.username,sys_users.nick_name AS nickname,sys_users.header_img AS avatar,sys_users.enable,sys_users.created_at,COALESCE(live_wallets.diamond_balance,0) AS diamond_balance,COALESCE(live_wallets.total_recharge_cents,0) AS total_recharge_cents,COALESCE(live_wallets.total_spent_diamonds,0) AS total_spent_diamonds,(SELECT MAX(created_at) FROM sys_login_logs WHERE user_id=sys_users.id AND status=?) AS last_login_at"
|
||||
if err := q.Select(sel, true).Order(userOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]*biz.User, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, toBiz(&rows[i]))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
func (r *repo) Get(ctx context.Context, id uint) (*biz.User, error) {
|
||||
items, _, err := r.List(ctx, biz.Filter{UserID: id}, biz.ListOptions{Limit: 1})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, biz.ErrNotFound
|
||||
}
|
||||
v := items[0]
|
||||
var orders []ledger.RechargeOrderPO
|
||||
if err := r.db(ctx).Where("user_id = ?", id).Order("created_at DESC").Limit(20).Find(&orders).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range orders {
|
||||
status := "pending"
|
||||
if x.CreditedAt != nil {
|
||||
status = "paid"
|
||||
}
|
||||
v.RecentOrders = append(v.RecentOrders, biz.OrderRecord{OrderNo: x.OrderNo, Diamonds: x.Diamonds, AmountCents: x.AmountCents, Status: status, CreatedAt: x.CreatedAt})
|
||||
}
|
||||
var sends []ledger.GiftSendPO
|
||||
if err := r.db(ctx).Where("user_id = ?", id).Order("created_at DESC").Limit(20).Find(&sends).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range sends {
|
||||
v.RecentSends = append(v.RecentSends, biz.SendRecord{SerialNo: x.SerialNo, RoomName: x.RoomName, GiftName: x.GiftName, GiftIcon: x.GiftIcon, DiamondTotal: x.DiamondTotal, CreatedAt: x.CreatedAt})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (r *repo) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
value := 2
|
||||
if enabled {
|
||||
value = 1
|
||||
}
|
||||
res := r.db(ctx).Table("sys_users").Where("id = ? AND deleted_at IS NULL", id).Update("enable", value)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return biz.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *repo) Adjust(ctx context.Context, v *biz.Adjustment) (*biz.User, error) {
|
||||
err := r.db(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
wallet := ledger.WalletPO{UserID: v.UserID}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", v.UserID).FirstOrCreate(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
balance := wallet.DiamondBalance
|
||||
if v.Type == "decrease" {
|
||||
if balance < v.Amount {
|
||||
return biz.ErrInsufficient
|
||||
}
|
||||
balance -= v.Amount
|
||||
} else {
|
||||
balance += v.Amount
|
||||
}
|
||||
if err := tx.Model(&wallet).Update("diamond_balance", balance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&ledger.BalanceAdjustmentPO{UserID: v.UserID, OperatorUserID: v.OperatorUserID, Type: v.Type, Amount: v.Amount, Reason: v.Reason, BalanceAfter: balance}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, v.UserID)
|
||||
}
|
||||
func toBiz(v *row) *biz.User {
|
||||
return &biz.User{UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, Avatar: v.Avatar, Enabled: v.Enable == 1, DiamondBalance: v.DiamondBalance, TotalRechargeCents: v.TotalRechargeCents, TotalSpentDiamonds: v.TotalSpentDiamonds, CreatedAt: v.CreatedAt, LastLoginAt: v.LastLoginAt}
|
||||
}
|
||||
func userOrder(v string) string {
|
||||
m := map[string]string{"id_desc": "sys_users.id DESC", "balance_asc": "diamond_balance ASC", "balance_desc": "diamond_balance DESC", "recharge_asc": "total_recharge_cents ASC", "recharge_desc": "total_recharge_cents DESC", "spent_asc": "total_spent_diamonds ASC", "spent_desc": "total_spent_diamonds DESC", "created_at_asc": "sys_users.created_at ASC", "created_at_desc": "sys_users.created_at DESC", "last_login_asc": "last_login_at ASC", "last_login_desc": "last_login_at DESC"}
|
||||
if x := m[v]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "sys_users.id ASC"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
bizasset "kra/internal/biz/asset"
|
||||
"kra/internal/data/ledger"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type testProvider struct{ db *gorm.DB }
|
||||
|
||||
func (p testProvider) DB() *gorm.DB { return p.db }
|
||||
|
||||
func TestAdjustmentRejectsInsufficientBalanceWithoutAudit(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{}, &ledger.RechargeOrderPO{}, &ledger.GiftSendPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text, header_img text, enable integer, created_at datetime, deleted_at datetime)`,
|
||||
`CREATE TABLE sys_login_logs (id integer primary key, user_id integer, status numeric, created_at datetime)`,
|
||||
} {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name,enable,created_at) VALUES (4,'user','User',1,CURRENT_TIMESTAMP)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&ledger.WalletPO{UserID: 4, DiamondBalance: 20}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
_, err = repo.Adjust(context.Background(), &bizasset.Adjustment{UserID: 4, OperatorUserID: 1, Type: "decrease", Amount: 30, Reason: "test reason"})
|
||||
if !errors.Is(err, bizasset.ErrInsufficient) {
|
||||
t.Fatalf("adjustment error = %v", err)
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
var audits int64
|
||||
db.Where("user_id = ?", 4).First(&wallet)
|
||||
db.Model(&ledger.BalanceAdjustmentPO{}).Count(&audits)
|
||||
if wallet.DiamondBalance != 20 || audits != 0 {
|
||||
t.Fatalf("failed adjustment changed state: wallet=%+v audits=%d", wallet, audits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190004_asset_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{})
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package asset
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewRepo)
|
||||
@@ -13,8 +13,16 @@ import (
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/config"
|
||||
dataanalytics "kra/internal/data/analytics"
|
||||
dataasset "kra/internal/data/asset"
|
||||
datgift "kra/internal/data/gift"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
datalive "kra/internal/data/live"
|
||||
datalivechat "kra/internal/data/livechat"
|
||||
dataliveclient "kra/internal/data/liveclient"
|
||||
datapayment "kra/internal/data/payment"
|
||||
dataplatformsetting "kra/internal/data/platformsetting"
|
||||
datarecharge "kra/internal/data/recharge"
|
||||
datasystem "kra/internal/data/system"
|
||||
datatask "kra/internal/data/task"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
@@ -30,7 +38,23 @@ var ProviderSet = wire.NewSet(
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
wire.Bind(new(dataintegration.Provider), new(*Data)),
|
||||
wire.Bind(new(datapayment.Provider), new(*Data)),
|
||||
wire.Bind(new(datalive.Provider), new(*Data)),
|
||||
wire.Bind(new(datalivechat.Provider), new(*Data)),
|
||||
wire.Bind(new(dataliveclient.Provider), new(*Data)),
|
||||
wire.Bind(new(datgift.Provider), new(*Data)),
|
||||
wire.Bind(new(datarecharge.Provider), new(*Data)),
|
||||
wire.Bind(new(dataasset.Provider), new(*Data)),
|
||||
wire.Bind(new(dataplatformsetting.Provider), new(*Data)),
|
||||
wire.Bind(new(dataanalytics.Provider), new(*Data)),
|
||||
datasystem.ProviderSet,
|
||||
datalive.ProviderSet,
|
||||
datalivechat.ProviderSet,
|
||||
dataliveclient.ProviderSet,
|
||||
datgift.ProviderSet,
|
||||
datarecharge.ProviderSet,
|
||||
dataasset.ProviderSet,
|
||||
dataplatformsetting.ProviderSet,
|
||||
dataanalytics.ProviderSet,
|
||||
dataintegration.ProviderSet,
|
||||
datatask.ProviderSet,
|
||||
datapayment.ProviderSet,
|
||||
|
||||
@@ -205,7 +205,16 @@ func openDatabase(c *config.Database, create bool, template string, appLogger *s
|
||||
case "mssql":
|
||||
bootstrap = "master"
|
||||
}
|
||||
dsn, err := databaseDSN(c, bootstrap)
|
||||
bootstrapConfig := c
|
||||
if driver == "mysql" {
|
||||
// MySQL creates the target schema after connecting to the server. Do
|
||||
// not include the not-yet-existing schema in the bootstrap DSN.
|
||||
copy := *c
|
||||
copy.Name = ""
|
||||
copy.Source = ""
|
||||
bootstrapConfig = ©
|
||||
}
|
||||
dsn, err := databaseDSN(bootstrapConfig, bootstrap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,20 +7,16 @@ import (
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func TestMySQLDatabaseDSNIncludesNetworkTimeouts(t *testing.T) {
|
||||
dsn, err := databaseDSN(&config.Database{
|
||||
Driver: "mysql",
|
||||
User: "root",
|
||||
Password: "secret",
|
||||
Host: "mysql",
|
||||
Name: "kra",
|
||||
}, "")
|
||||
func TestMySQLBootstrapDSNDoesNotRequireTargetDatabase(t *testing.T) {
|
||||
value := &config.Database{Driver: "mysql", Host: "127.0.0.1", Port: "3306", User: "root", Password: "secret", Name: "new_database", Config: "parseTime=True"}
|
||||
copy := *value
|
||||
copy.Name = ""
|
||||
copy.Source = ""
|
||||
dsn, err := databaseDSN(©, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, option := range []string{"timeout=5s", "readTimeout=5s", "writeTimeout=5s"} {
|
||||
if !strings.Contains(dsn, option) {
|
||||
t.Fatalf("DSN %q does not contain %q", dsn, option)
|
||||
}
|
||||
if strings.Contains(dsn, "/new_database?") || !strings.Contains(dsn, "/?parseTime=True") {
|
||||
t.Fatalf("bootstrap DSN still selects target database: %q", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package gift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
bizgift "kra/internal/biz/gift"
|
||||
"kra/internal/data/ledger"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
type giftPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
Name string `gorm:"size:60;uniqueIndex"`
|
||||
Icon string `gorm:"size:1000"`
|
||||
Price int64 `gorm:"index"`
|
||||
Description string `gorm:"size:255"`
|
||||
Tiers string `gorm:"type:text"`
|
||||
Sort int `gorm:"default:0;index"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
}
|
||||
|
||||
func (giftPO) TableName() string { return "live_gifts" }
|
||||
|
||||
type repo struct{ data Provider }
|
||||
|
||||
func NewRepo(data Provider) bizgift.Repo { return &repo{data: data} }
|
||||
func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) }
|
||||
func (r *repo) List(ctx context.Context, f bizgift.Filter, o bizgift.ListOptions) ([]*bizgift.Gift, int64, error) {
|
||||
q := r.db(ctx).Model(&giftPO{})
|
||||
if f.Name != "" {
|
||||
q = q.Where("name LIKE ?", "%"+f.Name+"%")
|
||||
}
|
||||
if f.MinPrice > 0 {
|
||||
q = q.Where("price >= ?", f.MinPrice)
|
||||
}
|
||||
if f.MaxPrice > 0 {
|
||||
q = q.Where("price <= ?", f.MaxPrice)
|
||||
}
|
||||
if f.Enabled != nil {
|
||||
q = q.Where("enabled = ?", *f.Enabled)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []giftPO
|
||||
if err := q.Order(giftOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]*bizgift.Gift, 0, len(rows))
|
||||
for i := range rows {
|
||||
item := toBiz(&rows[i])
|
||||
r.db(ctx).Model(&ledger.GiftSendPO{}).Where("gift_id = ?", item.ID).Count(&item.SendCount)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
func (r *repo) Get(ctx context.Context, id uint) (*bizgift.Gift, error) {
|
||||
var row giftPO
|
||||
if err := r.db(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, bizgift.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
item := toBiz(&row)
|
||||
r.db(ctx).Model(&ledger.GiftSendPO{}).Where("gift_id = ?", id).Count(&item.SendCount)
|
||||
return item, nil
|
||||
}
|
||||
func (r *repo) Save(ctx context.Context, item *bizgift.Gift) (*bizgift.Gift, error) {
|
||||
row := newPO(item)
|
||||
if item.ID == 0 {
|
||||
if err := r.db(ctx).Create(row).Error; err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
item.ID = row.ID
|
||||
} else {
|
||||
if !item.Enabled {
|
||||
if err := r.notLast(ctx, item.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
res := r.db(ctx).Model(&giftPO{}).Where("id = ?", item.ID).Updates(map[string]any{"name": item.Name, "icon": item.Icon, "price": item.Price, "description": item.Description, "tiers": encode(item.Tiers), "sort": item.Sort, "enabled": item.Enabled})
|
||||
if res.Error != nil {
|
||||
return nil, mapError(res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil, bizgift.ErrNotFound
|
||||
}
|
||||
}
|
||||
return r.Get(ctx, item.ID)
|
||||
}
|
||||
func (r *repo) Delete(ctx context.Context, id uint) error {
|
||||
if err := r.notLast(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
res := r.db(ctx).Delete(&giftPO{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return bizgift.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *repo) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
if !enabled {
|
||||
if err := r.notLast(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
res := r.db(ctx).Model(&giftPO{}).Where("id = ?", id).Update("enabled", enabled)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return bizgift.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *repo) SaveSort(ctx context.Context, values map[uint]int) error {
|
||||
return r.db(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for id, v := range values {
|
||||
if err := tx.Model(&giftPO{}).Where("id = ?", id).Update("sort", v).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func (r *repo) notLast(ctx context.Context, id uint) error {
|
||||
var row giftPO
|
||||
if err := r.db(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return bizgift.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !row.Enabled {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := r.db(ctx).Model(&giftPO{}).Where("enabled = ?", true).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count <= 1 {
|
||||
return bizgift.ErrLastEnabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type sendRow struct {
|
||||
ledger.GiftSendPO
|
||||
Username string `gorm:"column:username"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
RoomDeleted bool `gorm:"column:room_deleted"`
|
||||
}
|
||||
|
||||
func (r *repo) ListSends(ctx context.Context, f bizgift.SendFilter, o bizgift.ListOptions) ([]*bizgift.Send, int64, *bizgift.Summary, error) {
|
||||
q := sendQuery(r.db(ctx), f)
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
var sum struct {
|
||||
Diamonds int64
|
||||
AmountCents int64
|
||||
}
|
||||
if err := sendQuery(r.db(ctx), f).Select("COALESCE(SUM(live_gift_sends.diamond_total),0) AS diamonds,COALESCE(SUM(live_gift_sends.equivalent_cents),0) AS amount_cents").Scan(&sum).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
var rows []sendRow
|
||||
if err := q.Select("live_gift_sends.*,sys_users.username,sys_users.nick_name AS nickname,CASE WHEN live_rooms.id IS NULL THEN 1 ELSE 0 END AS room_deleted").Order(sendOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
items := make([]*bizgift.Send, 0, len(rows))
|
||||
for i := range rows {
|
||||
items = append(items, sendToBiz(&rows[i]))
|
||||
}
|
||||
return items, total, &bizgift.Summary{Count: total, Diamonds: sum.Diamonds, AmountCents: sum.AmountCents}, nil
|
||||
}
|
||||
func sendQuery(db *gorm.DB, f bizgift.SendFilter) *gorm.DB {
|
||||
q := db.Model(&ledger.GiftSendPO{}).Joins("LEFT JOIN sys_users ON sys_users.id = live_gift_sends.user_id").Joins("LEFT JOIN live_rooms ON live_rooms.id = live_gift_sends.room_id AND live_rooms.deleted_at IS NULL")
|
||||
if f.RoomName != "" {
|
||||
q = q.Where("live_gift_sends.room_name LIKE ?", "%"+f.RoomName+"%")
|
||||
}
|
||||
if f.AnchorName != "" {
|
||||
q = q.Where("live_gift_sends.anchor_name LIKE ?", "%"+f.AnchorName+"%")
|
||||
}
|
||||
if f.UserKeyword != "" {
|
||||
v := "%" + f.UserKeyword + "%"
|
||||
q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v)
|
||||
}
|
||||
if f.GiftID > 0 {
|
||||
q = q.Where("live_gift_sends.gift_id = ?", f.GiftID)
|
||||
}
|
||||
if len(f.CreatedAt) == 2 {
|
||||
q = q.Where("live_gift_sends.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1])
|
||||
}
|
||||
return q
|
||||
}
|
||||
func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity int) (*bizgift.Send, error) {
|
||||
var created uint
|
||||
err := r.db(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var room struct {
|
||||
ID uint
|
||||
Name string
|
||||
AnchorUserID uint
|
||||
AnchorName string
|
||||
}
|
||||
if err := tx.Table("live_rooms").Select("live_rooms.id,live_rooms.name,live_rooms.anchor_user_id,COALESCE(NULLIF(sys_users.nick_name,''),sys_users.username) AS anchor_name").Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id").Where("live_rooms.id = ? AND live_rooms.enabled = ? AND live_rooms.deleted_at IS NULL", roomID, true).Scan(&room).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if room.ID == 0 {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
var gift giftPO
|
||||
if err := tx.Where("id = ? AND enabled = ?", giftID, true).First(&gift).Error; err != nil {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
allowedQuantity := false
|
||||
for _, tier := range decode(gift.Tiers) {
|
||||
if tier == quantity {
|
||||
allowedQuantity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowedQuantity {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
wallet := ledger.WalletPO{UserID: userID}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).FirstOrCreate(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total := gift.Price * int64(quantity)
|
||||
if wallet.DiamondBalance < total {
|
||||
return bizgift.ErrInsufficientBalance
|
||||
}
|
||||
rate := int64(10)
|
||||
var setting ledger.SettingPO
|
||||
if err := tx.Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&setting).Error; err == nil {
|
||||
if v, e := strconv.ParseInt(setting.Value, 10, 64); e == nil {
|
||||
rate = v
|
||||
}
|
||||
}
|
||||
roomIDCopy, giftIDCopy := room.ID, gift.ID
|
||||
row := ledger.GiftSendPO{SerialNo: newNumber("DS"), UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&wallet).Updates(map[string]any{"diamond_balance": gorm.Expr("diamond_balance - ?", total), "total_spent_diamonds": gorm.Expr("total_spent_diamonds + ?", total)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = row.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row sendRow
|
||||
if err := r.db(ctx).Model(&ledger.GiftSendPO{}).Select("live_gift_sends.*,sys_users.username,sys_users.nick_name AS nickname,0 AS room_deleted").Joins("LEFT JOIN sys_users ON sys_users.id = live_gift_sends.user_id").Where("live_gift_sends.id = ?", created).Scan(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sendToBiz(&row), nil
|
||||
}
|
||||
func newPO(v *bizgift.Gift) *giftPO {
|
||||
return &giftPO{ID: v.ID, Name: v.Name, Icon: v.Icon, Price: v.Price, Description: v.Description, Tiers: encode(v.Tiers), Sort: v.Sort, Enabled: v.Enabled}
|
||||
}
|
||||
func toBiz(v *giftPO) *bizgift.Gift {
|
||||
return &bizgift.Gift{ID: v.ID, Name: v.Name, Icon: v.Icon, Price: v.Price, Description: v.Description, Tiers: decode(v.Tiers), Sort: v.Sort, Enabled: v.Enabled, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt}
|
||||
}
|
||||
func sendToBiz(v *sendRow) *bizgift.Send {
|
||||
return &bizgift.Send{ID: v.ID, SerialNo: v.SerialNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, RoomID: v.RoomID, RoomName: v.RoomName, RoomDeleted: v.RoomDeleted, AnchorUserID: v.AnchorUserID, AnchorName: v.AnchorName, GiftID: v.GiftID, GiftName: v.GiftName, GiftIcon: v.GiftIcon, GiftPrice: v.GiftPrice, Quantity: v.Quantity, DiamondTotal: v.DiamondTotal, RateCents: v.RateCents, EquivalentCents: v.EquivalentCents, CreatedAt: v.CreatedAt}
|
||||
}
|
||||
func encode(v []int) string { b, _ := json.Marshal(v); return string(b) }
|
||||
func decode(v string) []int { var out []int; _ = json.Unmarshal([]byte(v), &out); return out }
|
||||
func mapError(err error) error {
|
||||
s := strings.ToLower(err.Error())
|
||||
if strings.Contains(s, "unique") || strings.Contains(s, "duplicate") {
|
||||
return bizgift.ErrNameExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
func giftOrder(v string) string {
|
||||
m := map[string]string{"sort_desc": "sort DESC", "name_asc": "name ASC", "name_desc": "name DESC", "price_asc": "price ASC", "price_desc": "price DESC", "updated_at_asc": "updated_at ASC", "updated_at_desc": "updated_at DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "sort ASC,created_at DESC"
|
||||
}
|
||||
func sendOrder(v string) string {
|
||||
m := map[string]string{"created_at_asc": "created_at ASC", "price_asc": "gift_price ASC", "price_desc": "gift_price DESC", "quantity_asc": "quantity ASC", "quantity_desc": "quantity DESC", "total_asc": "diamond_total ASC", "total_desc": "diamond_total DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "created_at DESC"
|
||||
}
|
||||
func newNumber(prefix string) string {
|
||||
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package gift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
bizgift "kra/internal/biz/gift"
|
||||
"kra/internal/data/ledger"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type testProvider struct{ db *gorm.DB }
|
||||
|
||||
func (p testProvider) DB() *gorm.DB { return p.db }
|
||||
|
||||
func openGiftTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&giftPO{}, &ledger.WalletPO{}, &ledger.GiftSendPO{}, &ledger.SettingPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`,
|
||||
`CREATE TABLE live_rooms (id integer primary key, name text, anchor_user_id integer, enabled numeric, deleted_at datetime)`,
|
||||
} {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestGiftRepoProtectsLastEnabledGift(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Rose", Icon: "rose", Price: 1, Tiers: []int{1}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repo.SetEnabled(context.Background(), item.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) {
|
||||
t.Fatalf("disable last gift error = %v", err)
|
||||
}
|
||||
if err = repo.Delete(context.Background(), item.ID); !errors.Is(err, bizgift.ErrLastEnabled) {
|
||||
t.Fatalf("delete last gift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 5, Tiers: []int{1, 10}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); !errors.Is(err, bizgift.ErrSendNotAllowed) {
|
||||
t.Fatalf("unsupported quantity error = %v", err)
|
||||
}
|
||||
var before ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 100 {
|
||||
t.Fatalf("wallet changed after rejected send: wallet=%+v err=%v", before, err)
|
||||
}
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var after ledger.WalletPO
|
||||
var sends int64
|
||||
db.Where("user_id = ?", 1).First(&after)
|
||||
db.Model(&ledger.GiftSendPO{}).Count(&sends)
|
||||
if after.DiamondBalance != 50 || after.TotalSpentDiamonds != 50 || sends != 1 {
|
||||
t.Fatalf("atomic send result wallet=%+v sends=%d", after, sends)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gift
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190002_gift_schema", Migrate: func(db *gorm.DB) error {
|
||||
if err := migration.CreateMissingTables(db, &giftPO{}, &ledger.GiftSendPO{}); err != nil {
|
||||
return err
|
||||
}
|
||||
var n int64
|
||||
if err := db.Model(&giftPO{}).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]giftPO{{Name: "玫瑰", Icon: "🌹", Price: 1, Description: "一朵玫瑰,为主播送上心意", Tiers: "[1,10,66,1314]", Sort: 1, Enabled: true}, {Name: "星星", Icon: "⭐", Price: 10, Description: "点亮一颗星,助力主播上榜", Tiers: "[1,10,66,1314]", Sort: 2, Enabled: true}, {Name: "跑车", Icon: "🚗", Price: 66, Description: "豪车进场,全场焦点", Tiers: "[1,10,66,1314]", Sort: 3, Enabled: true}, {Name: "火箭", Icon: "🚀", Price: 520, Description: "火箭升空,点燃直播间", Tiers: "[1,10,66,520]", Sort: 4, Enabled: true}}).Error
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package gift
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewRepo)
|
||||
@@ -0,0 +1,81 @@
|
||||
package ledger
|
||||
|
||||
import "time"
|
||||
|
||||
type WalletPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"uniqueIndex"`
|
||||
DiamondBalance int64
|
||||
TotalRechargeCents int64
|
||||
TotalSpentDiamonds int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (WalletPO) TableName() string { return "live_wallets" }
|
||||
|
||||
type RechargePlanPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Sort int `gorm:"index"`
|
||||
Diamonds int64
|
||||
AmountCents int64
|
||||
Enabled bool `gorm:"default:true"`
|
||||
}
|
||||
|
||||
func (RechargePlanPO) TableName() string { return "live_recharge_plans" }
|
||||
|
||||
type RechargeOrderPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
OrderNo string `gorm:"size:80;uniqueIndex"`
|
||||
UserID uint `gorm:"index"`
|
||||
Diamonds int64
|
||||
AmountCents int64
|
||||
RateCents int64
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time
|
||||
CreditedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
func (RechargeOrderPO) TableName() string { return "live_recharge_orders" }
|
||||
|
||||
type GiftSendPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
SerialNo string `gorm:"size:80;uniqueIndex"`
|
||||
UserID uint `gorm:"index"`
|
||||
RoomID *uint `gorm:"index"`
|
||||
RoomName string `gorm:"size:120"`
|
||||
AnchorUserID uint `gorm:"index"`
|
||||
AnchorName string `gorm:"size:120"`
|
||||
GiftID *uint `gorm:"index"`
|
||||
GiftName string `gorm:"size:60"`
|
||||
GiftIcon string `gorm:"size:1000"`
|
||||
GiftPrice int64
|
||||
Quantity int
|
||||
DiamondTotal int64
|
||||
RateCents int64
|
||||
EquivalentCents int64
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
func (GiftSendPO) TableName() string { return "live_gift_sends" }
|
||||
|
||||
type BalanceAdjustmentPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"index"`
|
||||
OperatorUserID uint `gorm:"index"`
|
||||
Type string `gorm:"size:20"`
|
||||
Amount int64
|
||||
Reason string `gorm:"size:255"`
|
||||
BalanceAfter int64
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
func (BalanceAdjustmentPO) TableName() string { return "live_balance_adjustments" }
|
||||
|
||||
type SettingPO struct {
|
||||
Key string `gorm:"primaryKey;size:100"`
|
||||
Value string `gorm:"type:text"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SettingPO) TableName() string { return "live_settings" }
|
||||
@@ -0,0 +1,364 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
DB() *gorm.DB
|
||||
}
|
||||
|
||||
type categoryPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
Name string `gorm:"size:40;uniqueIndex"`
|
||||
Icon string `gorm:"size:500"`
|
||||
Sort int `gorm:"default:0;index"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
}
|
||||
|
||||
func (categoryPO) TableName() string { return "live_categories" }
|
||||
|
||||
type roomPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
Name string `gorm:"size:120;index"`
|
||||
CategoryID uint `gorm:"index"`
|
||||
CoverURL string `gorm:"size:1000"`
|
||||
Intro string `gorm:"size:500"`
|
||||
Sort int `gorm:"default:0;index"`
|
||||
AnchorUserID uint `gorm:"index"`
|
||||
StreamURL string `gorm:"size:1000"`
|
||||
ReplayURL string `gorm:"size:1000"`
|
||||
Enabled bool `gorm:"default:true;index"`
|
||||
}
|
||||
|
||||
func (roomPO) TableName() string { return "live_rooms" }
|
||||
|
||||
type roomRow struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt
|
||||
Name string
|
||||
CategoryID uint
|
||||
CoverURL string
|
||||
Intro string
|
||||
Sort int
|
||||
AnchorUserID uint
|
||||
StreamURL string
|
||||
ReplayURL string
|
||||
Enabled bool
|
||||
CategoryName string `gorm:"column:category_name"`
|
||||
AnchorName string `gorm:"column:anchor_name"`
|
||||
AnchorAvatar string `gorm:"column:anchor_avatar"`
|
||||
}
|
||||
|
||||
type categoryRow struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt
|
||||
Name string
|
||||
Icon string
|
||||
Sort int
|
||||
Enabled bool
|
||||
RoomCount int64 `gorm:"column:room_count"`
|
||||
}
|
||||
|
||||
type catalogRepo struct{ data Provider }
|
||||
|
||||
func NewCatalogRepo(data Provider) *catalogRepo { return &catalogRepo{data: data} }
|
||||
|
||||
func (r *catalogRepo) ListCategories(ctx context.Context, filter bizlive.CategoryFilter, options bizlive.ListOptions) ([]*bizlive.Category, int64, error) {
|
||||
db := r.db(ctx)
|
||||
if db == nil {
|
||||
return nil, 0, gorm.ErrInvalidDB
|
||||
}
|
||||
query := db.Model(&categoryPO{})
|
||||
if filter.Name != "" {
|
||||
query = query.Where("live_categories.name LIKE ?", "%"+filter.Name+"%")
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
query = query.Where("live_categories.enabled = ?", *filter.Enabled)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := categoryOrder(options.OrderBy)
|
||||
var rows []categoryRow
|
||||
err := query.
|
||||
Select("live_categories.*, COUNT(live_rooms.id) AS room_count").
|
||||
Joins("LEFT JOIN live_rooms ON live_rooms.category_id = live_categories.id AND live_rooms.deleted_at IS NULL AND live_rooms.enabled = ?", true).
|
||||
Group("live_categories.id").Order(order).Offset(options.Offset).Limit(options.Limit).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]*bizlive.Category, 0, len(rows))
|
||||
for index := range rows {
|
||||
items = append(items, categoryToBiz(&rows[index]))
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *catalogRepo) GetCategory(ctx context.Context, id uint) (*bizlive.Category, error) {
|
||||
var row categoryRow
|
||||
err := r.db(ctx).Model(&categoryPO{}).
|
||||
Select("live_categories.*, COUNT(live_rooms.id) AS room_count").
|
||||
Joins("LEFT JOIN live_rooms ON live_rooms.category_id = live_categories.id AND live_rooms.deleted_at IS NULL AND live_rooms.enabled = ?", true).
|
||||
Where("live_categories.id = ?", id).Group("live_categories.id").Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, bizlive.ErrCategoryNotFound
|
||||
}
|
||||
return categoryToBiz(&row), nil
|
||||
}
|
||||
|
||||
func (r *catalogRepo) CreateCategory(ctx context.Context, item *bizlive.Category) (*bizlive.Category, error) {
|
||||
po := newCategory(item)
|
||||
if err := r.db(ctx).Create(po).Error; err != nil {
|
||||
return nil, mapCategoryError(err)
|
||||
}
|
||||
return r.GetCategory(ctx, po.ID)
|
||||
}
|
||||
|
||||
func (r *catalogRepo) UpdateCategory(ctx context.Context, item *bizlive.Category) (*bizlive.Category, error) {
|
||||
result := r.db(ctx).Model(&categoryPO{}).Where("id = ?", item.ID).Updates(map[string]any{
|
||||
"name": item.Name, "icon": item.Icon, "sort": item.Sort, "enabled": item.Enabled,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, mapCategoryError(result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, bizlive.ErrCategoryNotFound
|
||||
}
|
||||
return r.GetCategory(ctx, item.ID)
|
||||
}
|
||||
|
||||
func (r *catalogRepo) DeleteCategory(ctx context.Context, id uint) error {
|
||||
db := r.db(ctx)
|
||||
var count int64
|
||||
if err := db.Model(&roomPO{}).Where("category_id = ?", id).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return bizlive.ErrCategoryInUse
|
||||
}
|
||||
result := db.Delete(&categoryPO{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return bizlive.ErrCategoryNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
|
||||
db := r.db(ctx)
|
||||
query := db.Model(&roomPO{}).
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
|
||||
if filter.Name != "" {
|
||||
query = query.Where("live_rooms.name LIKE ?", "%"+filter.Name+"%")
|
||||
}
|
||||
if filter.AnchorName != "" {
|
||||
value := "%" + filter.AnchorName + "%"
|
||||
query = query.Where("sys_users.nick_name LIKE ? OR sys_users.username LIKE ?", value, value)
|
||||
}
|
||||
if filter.CategoryID > 0 {
|
||||
query = query.Where("live_rooms.category_id = ?", filter.CategoryID)
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
query = query.Where("live_rooms.enabled = ?", *filter.Enabled)
|
||||
}
|
||||
if filter.Status == bizlive.RoomStatusDisabled {
|
||||
query = query.Where("live_rooms.enabled = ?", false)
|
||||
} else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive {
|
||||
query = query.Where("live_rooms.enabled = ?", true)
|
||||
}
|
||||
if len(filter.CreatedAt) == 2 {
|
||||
query = query.Where("live_rooms.created_at BETWEEN ? AND ?", filter.CreatedAt[0], filter.CreatedAt[1])
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []roomRow
|
||||
err := query.Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar").
|
||||
Order(roomOrder(options.OrderBy)).Offset(options.Offset).Limit(options.Limit).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]*bizlive.Room, 0, len(rows))
|
||||
for index := range rows {
|
||||
items = append(items, roomToBiz(&rows[index]))
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *catalogRepo) GetRoom(ctx context.Context, id uint) (*bizlive.Room, error) {
|
||||
var row roomRow
|
||||
err := r.db(ctx).Model(&roomPO{}).
|
||||
Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar").
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL").
|
||||
Where("live_rooms.id = ?", id).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, bizlive.ErrRoomNotFound
|
||||
}
|
||||
return roomToBiz(&row), nil
|
||||
}
|
||||
|
||||
func (r *catalogRepo) CreateRoom(ctx context.Context, item *bizlive.Room) (*bizlive.Room, error) {
|
||||
if err := r.validateRoomRelations(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
po := newRoom(item)
|
||||
if err := r.db(ctx).Create(po).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.GetRoom(ctx, po.ID)
|
||||
}
|
||||
|
||||
func (r *catalogRepo) UpdateRoom(ctx context.Context, item *bizlive.Room) (*bizlive.Room, error) {
|
||||
if err := r.validateRoomRelations(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := r.db(ctx).Model(&roomPO{}).Where("id = ?", item.ID).Updates(map[string]any{
|
||||
"name": item.Name, "category_id": item.CategoryID, "cover_url": item.CoverURL, "intro": item.Intro,
|
||||
"sort": item.Sort, "anchor_user_id": item.AnchorUserID, "stream_url": item.StreamURL, "replay_url": item.ReplayURL, "enabled": item.Enabled,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, bizlive.ErrRoomNotFound
|
||||
}
|
||||
return r.GetRoom(ctx, item.ID)
|
||||
}
|
||||
|
||||
func (r *catalogRepo) DeleteRoom(ctx context.Context, id uint) error {
|
||||
result := r.db(ctx).Delete(&roomPO{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return bizlive.ErrRoomNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *catalogRepo) UpdateRoomEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
result := r.db(ctx).Model(&roomPO{}).Where("id = ?", id).Update("enabled", enabled)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return bizlive.ErrRoomNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *catalogRepo) SaveRoomSort(ctx context.Context, values map[uint]int) error {
|
||||
return r.db(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for id, sortValue := range values {
|
||||
if err := tx.Model(&roomPO{}).Where("id = ?", id).Update("sort", sortValue).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *catalogRepo) validateRoomRelations(ctx context.Context, item *bizlive.Room) error {
|
||||
db := r.db(ctx)
|
||||
var category categoryPO
|
||||
if err := db.First(&category, item.CategoryID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return bizlive.ErrCategoryNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !category.Enabled {
|
||||
return bizlive.ErrCategoryDisabled
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table("sys_users").Where("id = ? AND deleted_at IS NULL", item.AnchorUserID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return bizlive.ErrAnchorNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *catalogRepo) db(ctx context.Context) *gorm.DB {
|
||||
if r == nil || r.data == nil || r.data.DB() == nil {
|
||||
return nil
|
||||
}
|
||||
return r.data.DB().WithContext(ctx)
|
||||
}
|
||||
|
||||
func newCategory(item *bizlive.Category) *categoryPO {
|
||||
return &categoryPO{Name: item.Name, Icon: item.Icon, Sort: item.Sort, Enabled: item.Enabled}
|
||||
}
|
||||
|
||||
func categoryToBiz(row *categoryRow) *bizlive.Category {
|
||||
return &bizlive.Category{ID: row.ID, Name: row.Name, Icon: row.Icon, Sort: row.Sort, Enabled: row.Enabled, RoomCount: row.RoomCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
|
||||
func newRoom(item *bizlive.Room) *roomPO {
|
||||
return &roomPO{Name: item.Name, CategoryID: item.CategoryID, CoverURL: item.CoverURL, Intro: item.Intro, Sort: item.Sort, AnchorUserID: item.AnchorUserID, StreamURL: item.StreamURL, ReplayURL: item.ReplayURL, Enabled: item.Enabled}
|
||||
}
|
||||
|
||||
func roomToBiz(row *roomRow) *bizlive.Room {
|
||||
return &bizlive.Room{ID: row.ID, Name: row.Name, CategoryID: row.CategoryID, CategoryName: row.CategoryName, CoverURL: row.CoverURL, Intro: row.Intro, Sort: row.Sort, AnchorUserID: row.AnchorUserID, AnchorName: row.AnchorName, AnchorAvatar: row.AnchorAvatar, StreamURL: row.StreamURL, ReplayURL: row.ReplayURL, Enabled: row.Enabled, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
|
||||
func categoryOrder(value string) string {
|
||||
allowed := map[string]string{"sort_asc": "live_categories.sort ASC", "sort_desc": "live_categories.sort DESC", "created_at_asc": "live_categories.created_at ASC", "created_at_desc": "live_categories.created_at DESC"}
|
||||
if order := allowed[strings.ToLower(value)]; order != "" {
|
||||
return order
|
||||
}
|
||||
return "live_categories.sort ASC, live_categories.created_at DESC"
|
||||
}
|
||||
|
||||
func roomOrder(value string) string {
|
||||
allowed := map[string]string{
|
||||
"sort_asc": "live_rooms.sort ASC", "sort_desc": "live_rooms.sort DESC", "id_asc": "live_rooms.id ASC", "id_desc": "live_rooms.id DESC",
|
||||
"name_asc": "live_rooms.name ASC", "name_desc": "live_rooms.name DESC", "created_at_asc": "live_rooms.created_at ASC", "created_at_desc": "live_rooms.created_at DESC",
|
||||
}
|
||||
if order := allowed[strings.ToLower(value)]; order != "" {
|
||||
return order
|
||||
}
|
||||
return "live_rooms.sort ASC, live_rooms.created_at DESC"
|
||||
}
|
||||
|
||||
func mapCategoryError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
if strings.Contains(message, "unique") || strings.Contains(message, "duplicate") {
|
||||
return bizlive.ErrCategoryNameExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type catalogTestProvider struct{ db *gorm.DB }
|
||||
|
||||
func (p catalogTestProvider) DB() *gorm.DB { return p.db }
|
||||
|
||||
func openCatalogTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&categoryPO{}, &roomPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE sys_users (
|
||||
id integer primary key,
|
||||
username text,
|
||||
nick_name text,
|
||||
header_img text,
|
||||
deleted_at datetime
|
||||
)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
|
||||
db := openCatalogTestDB(t)
|
||||
if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
category := categoryPO{Name: "Games", Icon: "game.png", Sort: 1, Enabled: true}
|
||||
if err := db.Create(&category).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, Enabled: true}
|
||||
if err := db.Create(&room).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo := NewCatalogRepo(catalogTestProvider{db: db})
|
||||
categories, total, err := repo.ListCategories(context.Background(), bizlive.CategoryFilter{}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(categories) != 1 || categories[0].ID != category.ID || categories[0].Name != category.Name || !categories[0].Enabled || categories[0].RoomCount != 1 {
|
||||
t.Fatalf("category scan result total=%d categories=%+v", total, categories)
|
||||
}
|
||||
|
||||
rooms, total, err := repo.ListRooms(context.Background(), bizlive.RoomFilter{}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(rooms) != 1 || rooms[0].ID != room.ID || rooms[0].Name != room.Name || rooms[0].CategoryName != category.Name || rooms[0].AnchorName != "Anchor" || !rooms[0].Enabled {
|
||||
t.Fatalf("room list scan result total=%d rooms=%+v", total, rooms)
|
||||
}
|
||||
|
||||
got, err := repo.GetRoom(context.Background(), room.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || !got.Enabled {
|
||||
t.Fatalf("room get scan result=%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190001_live_schema", Migrate: func(db *gorm.DB) error {
|
||||
if err := migration.CreateMissingTables(db, &categoryPO{}, &roomPO{}); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&categoryPO{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]categoryPO{{Name: "游戏竞技", Icon: "🎮", Sort: 1, Enabled: true}, {Name: "音乐区", Icon: "🎵", Sort: 2, Enabled: true}, {Name: "闲聊电台", Icon: "🌙", Sort: 3, Enabled: true}, {Name: "舞蹈才艺", Icon: "💃", Sort: 4, Enabled: true}, {Name: "娱乐直播", Icon: "🎪", Sort: 5, Enabled: true}}).Error
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
bizlive "kra/internal/biz/live"
|
||||
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewStreamRepo,
|
||||
NewCatalogRepo,
|
||||
wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)),
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
)
|
||||
|
||||
type streamState struct {
|
||||
stream *bizlive.Stream
|
||||
users map[uint]struct{}
|
||||
}
|
||||
|
||||
type streamRepo struct {
|
||||
mu sync.RWMutex
|
||||
streams map[string]*streamState
|
||||
}
|
||||
|
||||
func NewStreamRepo() bizlive.StreamRepo {
|
||||
return &streamRepo{streams: make(map[string]*streamState)}
|
||||
}
|
||||
|
||||
func (r *streamRepo) Get(_ context.Context, streamID string) (*bizlive.Stream, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
state := r.streams[streamID]
|
||||
if state == nil {
|
||||
return &bizlive.Stream{StreamID: streamID, UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
return clone(state.stream), nil
|
||||
}
|
||||
|
||||
func (r *streamRepo) List(_ context.Context) ([]*bizlive.Stream, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
items := make([]*bizlive.Stream, 0, len(r.streams))
|
||||
for _, state := range r.streams {
|
||||
if state.stream.PublisherOnline {
|
||||
items = append(items, clone(state.stream))
|
||||
}
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].StreamID < items[j].StreamID })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
state := r.streams[streamID]
|
||||
if state == nil {
|
||||
if len(r.streams) >= bizlive.MaxStreams {
|
||||
return nil, bizlive.ErrMaxStreams
|
||||
}
|
||||
state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})}
|
||||
r.streams[streamID] = state
|
||||
}
|
||||
if state.stream.PublisherOnline {
|
||||
return nil, bizlive.ErrPublisherExists
|
||||
}
|
||||
state.stream.PublisherID = userID
|
||||
state.stream.PublisherOnline = true
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
return clone(state.stream), nil
|
||||
}
|
||||
|
||||
func (r *streamRepo) UnregisterPublisher(_ context.Context, streamID string, userID uint) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
state := r.streams[streamID]
|
||||
if state == nil || !state.stream.PublisherOnline || state.stream.PublisherID != userID {
|
||||
return bizlive.ErrPublisherNotFound
|
||||
}
|
||||
state.stream.PublisherID = 0
|
||||
state.stream.PublisherOnline = false
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
if len(state.users) == 0 {
|
||||
delete(r.streams, streamID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *streamRepo) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
state := r.streams[streamID]
|
||||
if state == nil {
|
||||
if len(r.streams) >= bizlive.MaxStreams {
|
||||
return nil, bizlive.ErrMaxStreams
|
||||
}
|
||||
state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})}
|
||||
r.streams[streamID] = state
|
||||
}
|
||||
if _, exists := state.users[userID]; exists {
|
||||
return nil, bizlive.ErrSubscriberExists
|
||||
}
|
||||
state.users[userID] = struct{}{}
|
||||
state.stream.SubscriberCount = len(state.users)
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
return clone(state.stream), nil
|
||||
}
|
||||
|
||||
func (r *streamRepo) UnregisterSubscriber(_ context.Context, streamID string, userID uint) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
state := r.streams[streamID]
|
||||
if state == nil {
|
||||
return bizlive.ErrSubscriberNotFound
|
||||
}
|
||||
if _, exists := state.users[userID]; !exists {
|
||||
return bizlive.ErrSubscriberNotFound
|
||||
}
|
||||
delete(state.users, userID)
|
||||
state.stream.SubscriberCount = len(state.users)
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
if !state.stream.PublisherOnline && len(state.users) == 0 {
|
||||
delete(r.streams, streamID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clone(stream *bizlive.Stream) *bizlive.Stream {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
value := *stream
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package livechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
biz "kra/internal/biz/livechat"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
|
||||
type repo struct{ data Provider }
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
|
||||
type messagePO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
RoomID uint `gorm:"index"`
|
||||
Type string `gorm:"size:20;index"`
|
||||
UserID uint `gorm:"index"`
|
||||
Username string `gorm:"size:120"`
|
||||
Nickname string `gorm:"size:120"`
|
||||
Avatar string `gorm:"size:1000"`
|
||||
Content string `gorm:"size:255"`
|
||||
GiftID uint
|
||||
GiftName string `gorm:"size:60"`
|
||||
GiftIcon string `gorm:"size:1000"`
|
||||
Quantity int
|
||||
DiamondTotal int64
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
func (messagePO) TableName() string { return "live_chat_messages" }
|
||||
|
||||
type userSnapshot struct {
|
||||
ID uint
|
||||
Username string
|
||||
Nickname string
|
||||
Avatar string
|
||||
}
|
||||
|
||||
func (r *repo) ListRecent(ctx context.Context, roomID uint, limit int) ([]*biz.Message, error) {
|
||||
if err := r.ensureRoom(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []messagePO
|
||||
if err := r.data.DB().WithContext(ctx).Where("room_id = ?", roomID).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*biz.Message, 0, len(rows))
|
||||
for i := len(rows) - 1; i >= 0; i-- {
|
||||
out = append(out, toBiz(&rows[i]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *repo) CreateChat(ctx context.Context, roomID, userID uint, content string) (*biz.Message, error) {
|
||||
if err := r.ensureRoom(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := r.user(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := &messagePO{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content}
|
||||
if err = r.createAndPrune(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(row), nil
|
||||
}
|
||||
|
||||
func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Message, error) {
|
||||
if err := r.ensureRoom(ctx, gift.RoomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := r.user(ctx, gift.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username, nickname := gift.Username, gift.Nickname
|
||||
if username == "" {
|
||||
username = user.Username
|
||||
}
|
||||
if nickname == "" {
|
||||
nickname = user.Nickname
|
||||
}
|
||||
row := &messagePO{RoomID: gift.RoomID, Type: biz.TypeGift, UserID: gift.UserID, Username: username, Nickname: nickname, Avatar: user.Avatar, GiftID: gift.GiftID, GiftName: gift.GiftName, GiftIcon: gift.GiftIcon, Quantity: gift.Quantity, DiamondTotal: gift.DiamondTotal}
|
||||
if err = r.createAndPrune(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(row), nil
|
||||
}
|
||||
|
||||
func (r *repo) createAndPrune(ctx context.Context, row *messagePO) error {
|
||||
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var boundary messagePO
|
||||
err := tx.Select("id").Where("room_id = ?", row.RoomID).Order("id DESC").Offset(99).Limit(1).Take(&boundary).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("room_id = ? AND id < ?", row.RoomID, boundary.ID).Delete(&messagePO{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *repo) ensureRoom(ctx context.Context, roomID uint) error {
|
||||
var count int64
|
||||
if err := r.data.DB().WithContext(ctx).Table("live_rooms").Where("id = ? AND deleted_at IS NULL AND enabled = ?", roomID, true).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return biz.ErrRoomNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo) user(ctx context.Context, userID uint) (*userSnapshot, error) {
|
||||
var row userSnapshot
|
||||
if err := r.data.DB().WithContext(ctx).Table("sys_users").Select("id, username, nick_name AS nickname, header_img AS avatar").Where("id = ? AND deleted_at IS NULL AND enable = ?", userID, 1).Scan(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, biz.ErrUserNotFound
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func toBiz(row *messagePO) *biz.Message {
|
||||
return &biz.Message{ID: row.ID, RoomID: row.RoomID, Type: row.Type, UserID: row.UserID, Username: row.Username, Nickname: row.Nickname, Avatar: row.Avatar, Content: row.Content, GiftID: row.GiftID, GiftName: row.GiftName, GiftIcon: row.GiftIcon, Quantity: row.Quantity, DiamondTotal: row.DiamondTotal, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package livechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
biz "kra/internal/biz/livechat"
|
||||
)
|
||||
|
||||
type testProvider struct{ db *gorm.DB }
|
||||
|
||||
func (p testProvider) DB() *gorm.DB { return p.db }
|
||||
|
||||
func openChatTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&messagePO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE live_rooms (id integer primary key, enabled numeric, deleted_at datetime)`,
|
||||
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text, header_img text, enable integer, deleted_at datetime)`,
|
||||
`INSERT INTO live_rooms(id,enabled) VALUES (7,1)`,
|
||||
`INSERT INTO sys_users(id,username,nick_name,header_img,enable) VALUES (3,'viewer','Viewer','avatar.png',1)`,
|
||||
} {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestChatKeepsLatestHundredMessages(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
for index := 1; index <= 105; index++ {
|
||||
if _, err := repo.CreateChat(context.Background(), 7, 3, fmt.Sprintf("message-%03d", index)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, err := repo.ListRecent(context.Background(), 7, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 100 || items[0].Content != "message-006" || items[99].Content != "message-105" {
|
||||
t.Fatalf("history length=%d first=%q last=%q", len(items), items[0].Content, items[len(items)-1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftMessageUsesSnapshotAndUserAvatar(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
item, err := repo.CreateGift(context.Background(), &biz.GiftSnapshot{RoomID: 7, UserID: 3, Username: "viewer", Nickname: "Viewer", GiftID: 8, GiftName: "Star", GiftIcon: "star.png", Quantity: 10, DiamondTotal: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Type != biz.TypeGift || item.Avatar != "avatar.png" || item.GiftName != "Star" || item.Quantity != 10 || item.DiamondTotal != 50 {
|
||||
t.Fatalf("gift message = %+v", item)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package livechat
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190007_live_chat_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &messagePO{})
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package livechat
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewRepo)
|
||||
@@ -0,0 +1,165 @@
|
||||
package liveclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
biz "kra/internal/biz/liveclient"
|
||||
"kra/internal/data/ledger"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
|
||||
type repo struct{ data Provider }
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
|
||||
type userPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
UUID string
|
||||
Username string
|
||||
Password string
|
||||
NickName string `gorm:"column:nick_name"`
|
||||
HeaderImg string `gorm:"column:header_img"`
|
||||
AuthorityID uint `gorm:"column:authority_id"`
|
||||
Enable int
|
||||
PasswordUpdatedAt *time.Time
|
||||
MustChangePassword bool
|
||||
}
|
||||
|
||||
func (userPO) TableName() string { return "sys_users" }
|
||||
|
||||
type authorityPO struct {
|
||||
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
|
||||
AuthorityName string
|
||||
ParentID *uint
|
||||
DataScope int
|
||||
DefaultRouter string
|
||||
}
|
||||
|
||||
func (authorityPO) TableName() string { return "sys_authorities" }
|
||||
|
||||
type userAuthorityPO struct {
|
||||
UserID uint `gorm:"column:sys_user_id"`
|
||||
AuthorityID uint `gorm:"column:sys_authority_authority_id"`
|
||||
}
|
||||
|
||||
func (userAuthorityPO) TableName() string { return "sys_user_authority" }
|
||||
|
||||
type policyPO struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string
|
||||
V0, V1, V2 string
|
||||
V3, V4, V5 string
|
||||
}
|
||||
|
||||
func (policyPO) TableName() string { return "casbin_rule" }
|
||||
|
||||
func (r *repo) Register(ctx context.Context, account *biz.Account, passwordHash string, initialDiamonds int64) (*biz.Account, error) {
|
||||
now := time.Now()
|
||||
user := userPO{UUID: uuid.NewString(), Username: account.Username, Password: passwordHash, NickName: account.Nickname, HeaderImg: account.Avatar, AuthorityID: biz.ClientAuthorityID, Enable: 1, PasswordUpdatedAt: &now}
|
||||
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&userPO{}).Where("username = ?", account.Username).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return biz.ErrUsernameExists
|
||||
}
|
||||
root := uint(0)
|
||||
authority := authorityPO{AuthorityID: biz.ClientAuthorityID, AuthorityName: "直播用户", ParentID: &root, DataScope: 4, DefaultRouter: "404"}
|
||||
if err := tx.Where("authority_id = ?", authority.AuthorityID).FirstOrCreate(&authority).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, route := range clientRoutes() {
|
||||
policy := policyPO{Ptype: "p", V0: strconv.FormatUint(uint64(biz.ClientAuthorityID), 10), V1: route.path, V2: route.method}
|
||||
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).FirstOrCreate(&policy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
if duplicate(err) {
|
||||
return biz.ErrUsernameExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&userAuthorityPO{UserID: user.ID, AuthorityID: biz.ClientAuthorityID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&ledger.WalletPO{UserID: user.ID, DiamondBalance: initialDiamonds}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, user.ID)
|
||||
}
|
||||
|
||||
func clientRoutes() []struct{ method, path string } {
|
||||
return []struct{ method, path string }{
|
||||
{method: "GET", path: "/live/client/me"},
|
||||
{method: "PUT", path: "/live/client/me"},
|
||||
{method: "PUT", path: "/live/client/me/password"},
|
||||
{method: "POST", path: "/live/rooms/:id/gifts"},
|
||||
{method: "POST", path: "/live/recharge/orders"},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *repo) Get(ctx context.Context, userID uint) (*biz.Account, error) {
|
||||
var row struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UUID string
|
||||
Username string
|
||||
Nickname string
|
||||
Avatar string
|
||||
AuthorityID uint
|
||||
Enable int
|
||||
DiamondBalance int64
|
||||
PasswordUpdatedAt *time.Time
|
||||
}
|
||||
err := r.data.DB().WithContext(ctx).Table("sys_users").
|
||||
Select("sys_users.id, sys_users.created_at, sys_users.uuid, sys_users.username, sys_users.nick_name AS nickname, sys_users.header_img AS avatar, sys_users.authority_id, sys_users.enable, COALESCE(live_wallets.diamond_balance, 0) AS diamond_balance, sys_users.password_updated_at").
|
||||
Joins("LEFT JOIN live_wallets ON live_wallets.user_id = sys_users.id").
|
||||
Where("sys_users.id = ? AND sys_users.deleted_at IS NULL", userID).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, biz.ErrAccountNotFound
|
||||
}
|
||||
return &biz.Account{UserID: row.ID, UUID: row.UUID, Username: row.Username, Nickname: row.Nickname, Avatar: row.Avatar, AuthorityID: row.AuthorityID, Enabled: row.Enable == 1, DiamondBalance: row.DiamondBalance, CreatedAt: row.CreatedAt, PasswordUpdatedAt: row.PasswordUpdatedAt}, nil
|
||||
}
|
||||
|
||||
func (r *repo) UpdateProfile(ctx context.Context, in *biz.ProfileInput) (*biz.Account, error) {
|
||||
result := r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", in.UserID).Updates(map[string]any{"nick_name": in.Nickname, "header_img": in.Avatar})
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
var count int64
|
||||
if err := r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", in.UserID).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, biz.ErrAccountNotFound
|
||||
}
|
||||
}
|
||||
return r.Get(ctx, in.UserID)
|
||||
}
|
||||
|
||||
func duplicate(err error) bool {
|
||||
if err == nil || errors.Is(err, biz.ErrUsernameExists) {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "unique") || strings.Contains(text, "duplicate")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package liveclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
biz "kra/internal/biz/liveclient"
|
||||
"kra/internal/data/ledger"
|
||||
)
|
||||
|
||||
type testProvider struct{ db *gorm.DB }
|
||||
|
||||
func (p testProvider) DB() *gorm.DB { return p.db }
|
||||
|
||||
func openAccountTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&userPO{}, &authorityPO{}, &userAuthorityPO{}, &policyPO{}, &ledger.WalletPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRegisterCreatesCanonicalUserRoleWalletAndPolicies(t *testing.T) {
|
||||
db := openAccountTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
account, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Viewer"}, "password-hash", biz.InitialDiamonds)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.Username != "viewer" || account.AuthorityID != biz.ClientAuthorityID || account.DiamondBalance != biz.InitialDiamonds {
|
||||
t.Fatalf("account = %+v", account)
|
||||
}
|
||||
var user userPO
|
||||
var wallet ledger.WalletPO
|
||||
var links, policies int64
|
||||
if err = db.First(&user, account.UserID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Where("user_id = ?", account.UserID).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", account.UserID, biz.ClientAuthorityID).Count(&links)
|
||||
db.Model(&policyPO{}).Where("ptype = ? AND v0 = ?", "p", "1000").Count(&policies)
|
||||
if user.AuthorityID != biz.ClientAuthorityID || wallet.DiamondBalance != biz.InitialDiamonds || links != 1 || policies != int64(len(clientRoutes())) {
|
||||
t.Fatalf("registration state user=%+v wallet=%+v links=%d policies=%d", user, wallet, links, policies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicateUsernameDoesNotCreateAnotherWallet(t *testing.T) {
|
||||
db := openAccountTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if _, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Viewer"}, "hash", 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Other"}, "hash", 100); !errors.Is(err, biz.ErrUsernameExists) {
|
||||
t.Fatalf("duplicate error = %v", err)
|
||||
}
|
||||
var users, wallets int64
|
||||
db.Model(&userPO{}).Count(&users)
|
||||
db.Model(&ledger.WalletPO{}).Count(&wallets)
|
||||
if users != 1 || wallets != 1 {
|
||||
t.Fatalf("duplicate registration users=%d wallets=%d", users, wallets)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package liveclient
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewRepo)
|
||||
@@ -25,7 +25,7 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = migrateAll(db, testCatalog()); err != nil {
|
||||
t.Fatalf("second migration run: %v", err)
|
||||
}
|
||||
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events"} {
|
||||
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events", "live_chat_messages"} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
t.Fatalf("migration did not create %s", table)
|
||||
}
|
||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 13 {
|
||||
t.Fatalf("migration versions = %d, want 13", versions)
|
||||
if versions != 22 {
|
||||
t.Fatalf("migration versions = %d, want 22", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package platformsetting
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/pkg/database/migration"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190005_platform_setting_schema", Migrate: func(db *gorm.DB) error {
|
||||
if err := migration.CreateMissingTables(db, &ledger.SettingPO{}); err != nil {
|
||||
return err
|
||||
}
|
||||
row := ledger.SettingPO{Key: key, Value: "10", UpdatedAt: time.Now()}
|
||||
return db.Where(&ledger.SettingPO{Key: key}).FirstOrCreate(&row).Error
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package platformsetting
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewRepo)
|
||||
@@ -0,0 +1,38 @@
|
||||
package platformsetting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
biz "kra/internal/biz/platformsetting"
|
||||
"kra/internal/data/ledger"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const key = "diamond_rate_cents"
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
type repo struct{ data Provider }
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
func (r *repo) Get(ctx context.Context) (*biz.Setting, error) {
|
||||
var row ledger.SettingPO
|
||||
err := r.data.DB().WithContext(ctx).Where(&ledger.SettingPO{Key: key}).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &biz.Setting{DiamondRateCents: 10}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, e := strconv.ParseInt(row.Value, 10, 64)
|
||||
if e != nil {
|
||||
v = 10
|
||||
}
|
||||
return &biz.Setting{DiamondRateCents: v}, nil
|
||||
}
|
||||
func (r *repo) Save(ctx context.Context, v *biz.Setting) error {
|
||||
row := ledger.SettingPO{Key: key, Value: strconv.FormatInt(v.DiamondRateCents, 10), UpdatedAt: time.Now()}
|
||||
return r.data.DB().WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "key"}}, DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"})}).Create(&row).Error
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/data/ledger"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
// rechargeOrderV1 freezes the original schema owned by the first migration.
|
||||
type rechargeOrderV1 struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
OrderNo string `gorm:"size:80;uniqueIndex"`
|
||||
UserID uint `gorm:"index"`
|
||||
Diamonds int64
|
||||
AmountCents int64
|
||||
RateCents int64
|
||||
PaymentMethod string `gorm:"size:40;index"`
|
||||
Status string `gorm:"size:30;index"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
func (rechargeOrderV1) TableName() string { return "live_recharge_orders" }
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190003_recharge_schema", Migrate: func(db *gorm.DB) error {
|
||||
if err := migration.CreateMissingTables(db, &ledger.RechargePlanPO{}, &rechargeOrderV1{}); err != nil {
|
||||
return err
|
||||
}
|
||||
var n int64
|
||||
if err := db.Model(&ledger.RechargePlanPO{}).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]ledger.RechargePlanPO{{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true}, {Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true}, {Sort: 3, Diamonds: 980, AmountCents: 9800, Enabled: true}, {Sort: 4, Diamonds: 1980, AmountCents: 19800, Enabled: true}, {Sort: 5, Diamonds: 3280, AmountCents: 32800, Enabled: true}, {Sort: 6, Diamonds: 6980, AmountCents: 69800, Enabled: true}}).Error
|
||||
}}, {ID: "202609190007_recharge_payment_unification", Migrate: func(db *gorm.DB) error {
|
||||
migrator := db.Migrator()
|
||||
if !migrator.HasColumn(&ledger.RechargeOrderPO{}, "CreditedAt") {
|
||||
if err := migrator.AddColumn(&ledger.RechargeOrderPO{}, "CreditedAt"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, column := range []string{"payment_method", "status", "completed_at"} {
|
||||
if migrator.HasColumn(&rechargeOrderV1{}, column) {
|
||||
if err := migrator.DropColumn(&rechargeOrderV1{}, column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package recharge
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewRepo)
|
||||
@@ -0,0 +1,191 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
biz "kra/internal/biz/recharge"
|
||||
"kra/internal/data/ledger"
|
||||
)
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
type repo struct{ data Provider }
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) }
|
||||
|
||||
func (r *repo) Plans(ctx context.Context) ([]*biz.Plan, error) {
|
||||
var rows []ledger.RechargePlanPO
|
||||
if err := r.db(ctx).Where("enabled = ?", true).Order("sort ASC,id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*biz.Plan, 0, len(rows))
|
||||
for _, v := range rows {
|
||||
out = append(out, &biz.Plan{ID: v.ID, Sort: v.Sort, Diamonds: v.Diamonds, AmountCents: v.AmountCents, Enabled: v.Enabled})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error {
|
||||
return r.db(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ledger.RechargePlanPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range plans {
|
||||
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: true}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ledger.RechargeOrderPO
|
||||
Username string `gorm:"column:username"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
Provider string `gorm:"column:provider"`
|
||||
ProviderTradeNo string `gorm:"column:provider_trade_no"`
|
||||
PaymentStatus string `gorm:"column:payment_status"`
|
||||
FulfillmentStatus string `gorm:"column:fulfillment_status"`
|
||||
RefundStatus string `gorm:"column:refund_status"`
|
||||
}
|
||||
|
||||
func query(db *gorm.DB, f biz.Filter) *gorm.DB {
|
||||
q := db.Model(&ledger.RechargeOrderPO{}).
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_recharge_orders.user_id").
|
||||
Joins("LEFT JOIN pay_orders ON pay_orders.business_type = ? AND pay_orders.business_id = live_recharge_orders.order_no AND pay_orders.trade_no = live_recharge_orders.order_no", biz.BusinessType)
|
||||
if f.OrderNo != "" {
|
||||
q = q.Where("live_recharge_orders.order_no = ?", f.OrderNo)
|
||||
}
|
||||
if f.UserKeyword != "" {
|
||||
v := "%" + f.UserKeyword + "%"
|
||||
q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v)
|
||||
}
|
||||
if f.PaymentStatus != "" {
|
||||
q = q.Where("pay_orders.payment_status = ?", f.PaymentStatus)
|
||||
}
|
||||
if f.Provider != "" {
|
||||
q = q.Where("pay_orders.provider = ?", f.Provider)
|
||||
}
|
||||
if len(f.CreatedAt) == 2 {
|
||||
q = q.Where("live_recharge_orders.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1])
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
const orderSelect = "live_recharge_orders.*,sys_users.username,sys_users.nick_name AS nickname,pay_orders.provider,pay_orders.provider_trade_no,pay_orders.payment_status,pay_orders.fulfillment_status,pay_orders.refund_status"
|
||||
|
||||
func (r *repo) List(ctx context.Context, f biz.Filter, o biz.ListOptions) ([]*biz.Order, int64, *biz.Summary, error) {
|
||||
q := query(r.db(ctx), f)
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
var s struct{ PaidCount, AmountCents int64 }
|
||||
if err := query(r.db(ctx), f).Select("COALESCE(SUM(CASE WHEN live_recharge_orders.credited_at IS NOT NULL THEN 1 ELSE 0 END),0) AS paid_count,COALESCE(SUM(CASE WHEN live_recharge_orders.credited_at IS NOT NULL THEN live_recharge_orders.amount_cents ELSE 0 END),0) AS amount_cents").Scan(&s).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
var rows []row
|
||||
if err := q.Select(orderSelect).Order(orderBy(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
out := make([]*biz.Order, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, toBiz(&rows[i]))
|
||||
}
|
||||
return out, total, &biz.Summary{Count: total, PaidCount: s.PaidCount, AmountCents: s.AmountCents}, nil
|
||||
}
|
||||
|
||||
func (r *repo) Get(ctx context.Context, no string) (*biz.Order, error) {
|
||||
var v row
|
||||
if err := query(r.db(ctx), biz.Filter{OrderNo: no}).Select(orderSelect).Scan(&v).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.ID == 0 {
|
||||
return nil, biz.ErrNotFound
|
||||
}
|
||||
return toBiz(&v), nil
|
||||
}
|
||||
|
||||
func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, error) {
|
||||
var plan ledger.RechargePlanPO
|
||||
if err := r.db(ctx).Where("id = ? AND enabled = ?", planID, true).First(&plan).Error; err != nil {
|
||||
return nil, biz.ErrInvalid
|
||||
}
|
||||
rate := int64(10)
|
||||
var set ledger.SettingPO
|
||||
if err := r.db(ctx).Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&set).Error; err == nil {
|
||||
if v, e := strconv.ParseInt(set.Value, 10, 64); e == nil {
|
||||
rate = v
|
||||
}
|
||||
}
|
||||
v := ledger.RechargeOrderPO{OrderNo: number("CZ"), UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
|
||||
if err := r.db(ctx).Create(&v).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, v.OrderNo)
|
||||
}
|
||||
|
||||
func (r *repo) MarkPaid(ctx context.Context, no string) (*biz.Order, error) {
|
||||
var credited ledger.RechargeOrderPO
|
||||
err := r.db(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var v ledger.RechargeOrderPO
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", no).First(&v).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return biz.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if v.CreditedAt != nil {
|
||||
credited = v
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
v.CreditedAt = &now
|
||||
if err := tx.Save(&v).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
wallet := ledger.WalletPO{UserID: v.UserID}
|
||||
if err := tx.Where("user_id = ?", v.UserID).FirstOrCreate(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&wallet).Updates(map[string]any{"diamond_balance": gorm.Expr("diamond_balance + ?", v.Diamonds), "total_recharge_cents": gorm.Expr("total_recharge_cents + ?", v.AmountCents)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
credited = v
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(&row{RechargeOrderPO: credited}), nil
|
||||
}
|
||||
|
||||
func toBiz(v *row) *biz.Order {
|
||||
return &biz.Order{
|
||||
ID: v.ID, OrderNo: v.OrderNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname,
|
||||
Diamonds: v.Diamonds, AmountCents: v.AmountCents, Provider: v.Provider, ProviderTradeNo: v.ProviderTradeNo,
|
||||
PaymentStatus: v.PaymentStatus, FulfillmentStatus: v.FulfillmentStatus, RefundStatus: v.RefundStatus,
|
||||
CreatedAt: v.CreatedAt, CreditedAt: v.CreditedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func orderBy(v string) string {
|
||||
m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "live_recharge_orders.created_at DESC"
|
||||
}
|
||||
|
||||
func number(p string) string {
|
||||
return fmt.Sprintf("%s%s%06d", p, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
biz "kra/internal/biz/recharge"
|
||||
"kra/internal/data/ledger"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type testProvider struct{ db *gorm.DB }
|
||||
|
||||
func (p testProvider) DB() *gorm.DB { return p.db }
|
||||
|
||||
func TestMarkPaidCreditsWalletOnlyOnce(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.RechargeOrderPO{}, &ledger.WalletPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := ledger.RechargeOrderPO{OrderNo: "CZ-1", UserID: 3, Diamonds: 60, AmountCents: 600}
|
||||
if err = db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wallet ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.DiamondBalance != 60 || wallet.TotalRechargeCents != 600 {
|
||||
t.Fatalf("wallet credited more than once: %+v", wallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReadsPaymentStateFromKRAOrders(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ledger.RechargeOrderPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE pay_orders (trade_no text, business_type text, business_id text, provider text, provider_trade_no text, payment_status text, fulfillment_status text, refund_status text)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creditedAt := time.Now()
|
||||
order := ledger.RechargeOrderPO{OrderNo: "CZ-2", UserID: 3, Diamonds: 300, AmountCents: 3000, CreditedAt: &creditedAt}
|
||||
if err = db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
pending, err := repo.Get(context.Background(), order.OrderNo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending.Provider != "" || pending.PaymentStatus != "" {
|
||||
t.Fatalf("new business order unexpectedly has payment state: %+v", pending)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO pay_orders(trade_no,business_type,business_id,provider,provider_trade_no,payment_status,fulfillment_status,refund_status) VALUES (?,?,?,?,?,?,?,?)`, order.OrderNo, biz.BusinessType, order.OrderNo, "wechat-v3", "WX-1", "paid", "succeeded", "none").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, total, summary, err := repo.List(context.Background(), biz.Filter{Provider: "wechat-v3", PaymentStatus: "paid"}, biz.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 || summary.PaidCount != 1 || summary.AmountCents != 3000 {
|
||||
t.Fatalf("unexpected list result: total=%d items=%d summary=%+v", total, len(items), summary)
|
||||
}
|
||||
item := items[0]
|
||||
if item.Provider != "wechat-v3" || item.ProviderTradeNo != "WX-1" || item.PaymentStatus != "paid" || item.FulfillmentStatus != "succeeded" || item.RefundStatus != "none" {
|
||||
t.Fatalf("payment state was not joined: %+v", item)
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,50 @@ func EnsureAdminSurface(db *gorm.DB, surface platformmodule.Surface, rootAuthori
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveAdminMenus removes obsolete menu metadata without touching business
|
||||
// data. It is used by module migrations when a menu is intentionally deleted
|
||||
// from the current admin surface.
|
||||
func RemoveAdminMenus(db *gorm.DB, names ...string) error {
|
||||
if db == nil || len(names) == 0 || !db.Migrator().HasTable(&menuPO{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var menus []menuPO
|
||||
if err := tx.Where("name IN ?", names).Find(&menus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(menus) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]uint, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
ids = append(ids, menu.ID)
|
||||
}
|
||||
if tx.Migrator().HasTable(&authorityMenuPO{}) {
|
||||
if err := tx.Where("sys_base_menu_id IN ?", ids).Delete(&authorityMenuPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Migrator().HasTable(&authorityButtonPO{}) {
|
||||
if err := tx.Where("sys_menu_id IN ?", ids).Delete(&authorityButtonPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Migrator().HasTable(&menuButtonPO{}) {
|
||||
if err := tx.Unscoped().Where("sys_base_menu_id IN ?", ids).Delete(&menuButtonPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Migrator().HasTable(&menuParameterPO{}) {
|
||||
if err := tx.Unscoped().Where("sys_base_menu_id IN ?", ids).Delete(&menuParameterPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Unscoped().Where("id IN ?", ids).Delete(&menuPO{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// InheritAPIPolicy grants a new route only to authorities that already own
|
||||
// the supplied predecessor route. This preserves existing read-access intent
|
||||
// without broadening unrelated write permissions.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package live
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(New)
|
||||
@@ -0,0 +1,714 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
bizgift "kra/internal/biz/gift"
|
||||
bizlive "kra/internal/biz/live"
|
||||
bizchat "kra/internal/biz/livechat"
|
||||
platformws "kra/pkg/websocket"
|
||||
|
||||
"github.com/olahol/melody"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
hub platformws.Hub
|
||||
streams *bizlive.StreamUsecase
|
||||
roomCatalog *bizlive.RoomUsecase
|
||||
chat *bizchat.Usecase
|
||||
logger *slog.Logger
|
||||
api *webrtc.API
|
||||
mu sync.RWMutex
|
||||
rooms map[string]*room
|
||||
peers map[*melody.Session]*peer
|
||||
observers map[*melody.Session]struct{}
|
||||
nextSub atomic.Uint64
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
type room struct {
|
||||
mu sync.RWMutex
|
||||
streamID string
|
||||
publisher *peer
|
||||
viewers map[*peer]struct{}
|
||||
tracks map[webrtc.RTPCodecType]*publishedTrack
|
||||
}
|
||||
|
||||
type publishedTrack struct {
|
||||
id string
|
||||
kind webrtc.RTPCodecType
|
||||
local *webrtc.TrackLocalStaticRTP
|
||||
}
|
||||
|
||||
type peer struct {
|
||||
session *melody.Session
|
||||
manager *Manager
|
||||
room *room
|
||||
streamID string
|
||||
userID uint
|
||||
role string
|
||||
subscriberID string
|
||||
pc *webrtc.PeerConnection
|
||||
lastBeat atomic.Int64
|
||||
cleanup sync.Once
|
||||
}
|
||||
|
||||
type message struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type joinPayload struct {
|
||||
Role string `json:"role"`
|
||||
StreamID string `json:"streamId"`
|
||||
}
|
||||
|
||||
type sessionDescriptionPayload struct {
|
||||
Type string `json:"type"`
|
||||
SDP string `json:"sdp"`
|
||||
}
|
||||
|
||||
type chatSendPayload struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func newPionAPI() (*webrtc.API, error) {
|
||||
settings := webrtc.SettingEngine{}
|
||||
minPort := envPort("KRA_LIVE_UDP_MIN_PORT")
|
||||
maxPort := envPort("KRA_LIVE_UDP_MAX_PORT")
|
||||
if minPort > 0 || maxPort > 0 {
|
||||
if minPort == 0 || maxPort == 0 || minPort > maxPort {
|
||||
return nil, fmt.Errorf("invalid KRA_LIVE_UDP_MIN_PORT/KRA_LIVE_UDP_MAX_PORT")
|
||||
}
|
||||
if err := settings.SetEphemeralUDPPortRange(minPort, maxPort); err != nil {
|
||||
return nil, fmt.Errorf("configure live udp port range: %w", err)
|
||||
}
|
||||
}
|
||||
return webrtc.NewAPI(webrtc.WithSettingEngine(settings)), nil
|
||||
}
|
||||
|
||||
func envPort(key string) uint16 {
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(os.Getenv(key)), 10, 16)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return uint16(value)
|
||||
}
|
||||
|
||||
func liveICEServers() []webrtc.ICEServer {
|
||||
value := strings.TrimSpace(os.Getenv("KRA_LIVE_STUN_URL"))
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return []webrtc.ICEServer{{URLs: []string{value}}}
|
||||
}
|
||||
|
||||
func New(hub platformws.Hub, streams *bizlive.StreamUsecase, rooms *bizlive.RoomUsecase, chat *bizchat.Usecase, logger *slog.Logger) (*Manager, func(), error) {
|
||||
if hub == nil {
|
||||
return nil, nil, errors.New("live websocket hub is unavailable")
|
||||
}
|
||||
if streams == nil {
|
||||
return nil, nil, errors.New("live stream usecase is unavailable")
|
||||
}
|
||||
if rooms == nil {
|
||||
return nil, nil, errors.New("live room usecase is unavailable")
|
||||
}
|
||||
if chat == nil {
|
||||
return nil, nil, errors.New("live chat usecase is unavailable")
|
||||
}
|
||||
api, err := newPionAPI()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
m := &Manager{hub: hub, streams: streams, roomCatalog: rooms, chat: chat, logger: logger, api: api, rooms: make(map[string]*room), peers: make(map[*melody.Session]*peer), observers: make(map[*melody.Session]struct{}), stop: make(chan struct{})}
|
||||
hub.OnMessage(m.onMessage)
|
||||
hub.OnDisconnect(m.onDisconnect)
|
||||
go m.heartbeatLoop()
|
||||
return m, m.Close, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
m.stopOnce.Do(func() { close(m.stop) })
|
||||
m.mu.Lock()
|
||||
peers := make([]*peer, 0, len(m.peers))
|
||||
for _, p := range m.peers {
|
||||
peers = append(peers, p)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, p := range peers {
|
||||
m.closePeer(p, "server_shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) onMessage(session *melody.Session, raw []byte) {
|
||||
var msg message
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "INVALID_JSON", "message": "invalid json"})
|
||||
return
|
||||
}
|
||||
switch msg.Type {
|
||||
case "Join":
|
||||
var payload joinPayload
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "invalid join payload"})
|
||||
return
|
||||
}
|
||||
m.join(session, payload)
|
||||
case "Offer":
|
||||
var payload sessionDescriptionPayload
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "INVALID_OFFER", "message": "invalid offer payload"})
|
||||
return
|
||||
}
|
||||
m.handleOffer(session, payload)
|
||||
case "Heartbeat":
|
||||
if p := m.peer(session); p != nil {
|
||||
p.lastBeat.Store(time.Now().UnixMilli())
|
||||
}
|
||||
m.send(session, "HeartbeatAck", map[string]any{"streamId": peerStreamID(m, session)})
|
||||
case "Leave":
|
||||
if p := m.peer(session); p != nil {
|
||||
m.closePeer(p, "left")
|
||||
}
|
||||
case "ChatSend":
|
||||
var payload chatSendPayload
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "INVALID_CHAT", "message": "invalid chat payload"})
|
||||
return
|
||||
}
|
||||
m.handleChat(session, payload)
|
||||
default:
|
||||
m.send(session, "Error", map[string]any{"code": "UNKNOWN_MESSAGE", "message": "unknown message type"})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
role := strings.ToLower(strings.TrimSpace(payload.Role))
|
||||
streamID := strings.TrimSpace(payload.StreamID)
|
||||
if role == "observer" {
|
||||
m.mu.Lock()
|
||||
m.observers[session] = struct{}{}
|
||||
m.mu.Unlock()
|
||||
m.send(session, "Joined", map[string]any{"role": "observer"})
|
||||
m.sendStreamList(session)
|
||||
return
|
||||
}
|
||||
if streamID == "" || (role != "publisher" && role != "subscriber") {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "role and streamId are required"})
|
||||
return
|
||||
}
|
||||
userID, ok := sessionUserID(session)
|
||||
if !ok {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required"})
|
||||
return
|
||||
}
|
||||
if existing := m.peer(session); existing != nil {
|
||||
m.closePeer(existing, "rejoin")
|
||||
}
|
||||
r := m.getOrCreateRoom(streamID)
|
||||
if role == "publisher" {
|
||||
if err := m.authorizeRoomPublisher(streamID, userID); err != nil {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "PUBLISH_FORBIDDEN", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
occupied := r.publisher != nil
|
||||
r.mu.RUnlock()
|
||||
if occupied {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "DUPLICATE_PUBLISHER", "message": "stream already has a publisher"})
|
||||
return
|
||||
}
|
||||
if _, err := m.streams.RegisterPublisher(context.Background(), streamID, userID); err != nil {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role}
|
||||
p.lastBeat.Store(time.Now().UnixMilli())
|
||||
r.mu.Lock()
|
||||
r.publisher = p
|
||||
r.mu.Unlock()
|
||||
m.mu.Lock()
|
||||
m.peers[session] = p
|
||||
m.mu.Unlock()
|
||||
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "mode": "pion-sfu"})
|
||||
m.sendChatHistory(session, streamID)
|
||||
m.sendPublisherStatus(streamID, false, "publisher_waiting_for_offer")
|
||||
m.broadcastStreamList()
|
||||
return
|
||||
}
|
||||
|
||||
// Match the old behavior: a user can only watch a room once. The old
|
||||
// connection is cleaned up before registering the new one.
|
||||
var old *peer
|
||||
r.mu.RLock()
|
||||
for viewer := range r.viewers {
|
||||
if viewer.userID == userID {
|
||||
old = viewer
|
||||
break
|
||||
}
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
if old != nil {
|
||||
m.send(old.session, "Kicked", map[string]any{"reason": "logged_in_elsewhere", "streamId": streamID})
|
||||
m.closePeer(old, "logged_in_elsewhere")
|
||||
}
|
||||
if _, err := m.streams.RegisterSubscriber(context.Background(), streamID, userID); err != nil {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, subscriberID: "sub_" + strconv.FormatUint(m.nextSub.Add(1), 10)}
|
||||
p.lastBeat.Store(time.Now().UnixMilli())
|
||||
r.mu.Lock()
|
||||
r.viewers[p] = struct{}{}
|
||||
ready := r.publisher != nil && len(r.tracks) > 0
|
||||
r.mu.Unlock()
|
||||
m.mu.Lock()
|
||||
m.peers[session] = p
|
||||
m.mu.Unlock()
|
||||
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "subscriberId": p.subscriberID, "userId": userID, "mode": "pion-sfu"})
|
||||
m.sendChatHistory(session, streamID)
|
||||
m.sendPublisherStatusTo(session, streamID, ready, "publisher_offline")
|
||||
if ready {
|
||||
m.send(session, "PublisherStatus", map[string]any{"streamId": streamID, "ready": true})
|
||||
}
|
||||
if publisher := m.publisher(r); publisher != nil {
|
||||
m.send(publisher.session, "NewSubscriber", map[string]any{"streamId": streamID, "subscriberId": p.subscriberID, "userId": userID})
|
||||
}
|
||||
m.broadcastStreamList()
|
||||
}
|
||||
|
||||
func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) {
|
||||
p := m.peer(session)
|
||||
if p == nil || (p.role != "publisher" && p.role != "subscriber") {
|
||||
m.send(session, "Error", map[string]any{"code": "NOT_JOINED", "message": "join a room before chatting"})
|
||||
return
|
||||
}
|
||||
roomID, ok := roomIDFromStream(p.streamID)
|
||||
if !ok {
|
||||
m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"})
|
||||
return
|
||||
}
|
||||
item, err := m.chat.Send(context.Background(), roomID, p.userID, payload.Content)
|
||||
if err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "INVALID_CHAT", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
m.broadcastRoom(p.streamID, "ChatMessage", chatMessagePayload(item))
|
||||
}
|
||||
|
||||
func (m *Manager) sendChatHistory(session *melody.Session, streamID string) {
|
||||
roomID, ok := roomIDFromStream(streamID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := m.chat.ListRecent(context.Background(), roomID, 100)
|
||||
if err != nil {
|
||||
m.log("load live chat history failed", "room_id", roomID, "error", err)
|
||||
return
|
||||
}
|
||||
messages := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
messages = append(messages, chatMessagePayload(item))
|
||||
}
|
||||
m.send(session, "ChatHistory", map[string]any{"roomId": roomID, "messages": messages})
|
||||
}
|
||||
|
||||
// GiftSent implements gift.Notifier. Gift persistence and wallet debit have
|
||||
// already committed; chat persistence is therefore best-effort and must never
|
||||
// turn a successful debit into a failed gift response.
|
||||
func (m *Manager) GiftSent(ctx context.Context, sent *bizgift.Send) {
|
||||
if sent == nil || sent.RoomID == nil || sent.GiftID == nil {
|
||||
return
|
||||
}
|
||||
item, err := m.chat.RecordGift(ctx, &bizchat.GiftSnapshot{RoomID: *sent.RoomID, UserID: sent.UserID, Username: sent.Username, Nickname: sent.Nickname, GiftID: *sent.GiftID, GiftName: sent.GiftName, GiftIcon: sent.GiftIcon, Quantity: sent.Quantity, DiamondTotal: sent.DiamondTotal})
|
||||
if err != nil {
|
||||
m.log("record live gift chat failed", "room_id", *sent.RoomID, "gift_send_id", sent.ID, "error", err)
|
||||
return
|
||||
}
|
||||
m.broadcastRoom("room-"+strconv.FormatUint(uint64(*sent.RoomID), 10), "GiftMessage", chatMessagePayload(item))
|
||||
}
|
||||
|
||||
func (m *Manager) broadcastRoom(streamID, msgType string, payload any) {
|
||||
m.mu.RLock()
|
||||
peers := make([]*peer, 0)
|
||||
for _, p := range m.peers {
|
||||
if p.streamID == streamID && (p.role == "publisher" || p.role == "subscriber") {
|
||||
peers = append(peers, p)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, p := range peers {
|
||||
m.send(p.session, msgType, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func chatMessagePayload(item *bizchat.Message) map[string]any {
|
||||
return map[string]any{"id": item.ID, "roomId": item.RoomID, "type": item.Type, "userId": item.UserID, "username": item.Username, "nickname": item.Nickname, "avatar": item.Avatar, "content": item.Content, "giftId": item.GiftID, "giftName": item.GiftName, "giftIcon": item.GiftIcon, "quantity": item.Quantity, "diamondTotal": item.DiamondTotal, "createdAt": item.CreatedAt}
|
||||
}
|
||||
|
||||
func roomIDFromStream(streamID string) (uint, bool) {
|
||||
if !strings.HasPrefix(streamID, "room-") {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimPrefix(streamID, "room-"), 10, 64)
|
||||
return uint(value), err == nil && value > 0
|
||||
}
|
||||
|
||||
func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error {
|
||||
if !strings.HasPrefix(streamID, "room-") {
|
||||
return nil
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(streamID, "room-"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New("invalid live room stream id")
|
||||
}
|
||||
item, err := m.roomCatalog.Get(context.Background(), uint(id))
|
||||
if err != nil {
|
||||
return errors.New("live room is unavailable")
|
||||
}
|
||||
if !item.Enabled {
|
||||
return errors.New("live room is disabled")
|
||||
}
|
||||
if item.AnchorUserID != userID {
|
||||
return errors.New("current user is not the room anchor")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptionPayload) {
|
||||
p := m.peer(session)
|
||||
if p == nil || (p.role != "publisher" && p.role != "subscriber") {
|
||||
m.send(session, "Error", map[string]any{"code": "NOT_JOINED", "message": "join a stream first"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(payload.SDP) == "" {
|
||||
m.send(session, "Error", map[string]any{"code": "INVALID_SDP", "message": "sdp is required"})
|
||||
return
|
||||
}
|
||||
if p.pc != nil {
|
||||
_ = p.pc.Close()
|
||||
}
|
||||
pc, err := m.api.NewPeerConnection(webrtc.Configuration{ICEServers: liveICEServers()})
|
||||
if err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "PEER_CONNECTION", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p.pc = pc
|
||||
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
if state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed {
|
||||
m.closePeer(p, "ice_"+state.String())
|
||||
}
|
||||
})
|
||||
if p.role == "publisher" {
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
m.onPublisherTrack(p, track)
|
||||
})
|
||||
} else {
|
||||
for _, track := range m.trackSnapshot(p.room) {
|
||||
if sender, addErr := pc.AddTrack(track.local); addErr == nil {
|
||||
go drainRTCP(sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: payload.SDP}); err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "REMOTE_DESCRIPTION", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "CREATE_ANSWER", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if err = pc.SetLocalDescription(answer); err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "LOCAL_DESCRIPTION", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
<-webrtc.GatheringCompletePromise(pc)
|
||||
local := pc.LocalDescription()
|
||||
if local == nil {
|
||||
m.send(session, "Error", map[string]any{"code": "NO_LOCAL_DESCRIPTION", "message": "local description unavailable"})
|
||||
return
|
||||
}
|
||||
m.send(session, "Answer", map[string]any{"type": local.Type.String(), "sdp": local.SDP})
|
||||
if p.role == "publisher" {
|
||||
m.sendPublisherStatus(p.streamID, false, "waiting_for_tracks")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) onPublisherTrack(p *peer, remote *webrtc.TrackRemote) {
|
||||
codec := remote.Codec().RTPCodecCapability
|
||||
local, err := webrtc.NewTrackLocalStaticRTP(codec, remote.ID(), p.streamID)
|
||||
if err != nil {
|
||||
m.log("live track creation failed", "error", err)
|
||||
return
|
||||
}
|
||||
entry := &publishedTrack{id: remote.ID(), kind: remote.Kind(), local: local}
|
||||
p.room.mu.Lock()
|
||||
p.room.tracks[remote.Kind()] = entry
|
||||
viewers := make([]*peer, 0, len(p.room.viewers))
|
||||
for viewer := range p.room.viewers {
|
||||
viewers = append(viewers, viewer)
|
||||
}
|
||||
p.room.mu.Unlock()
|
||||
m.sendPublisherStatus(p.streamID, true, "")
|
||||
for _, viewer := range viewers {
|
||||
m.send(viewer.session, "NewProducer", map[string]any{"streamId": p.streamID, "producerId": remote.ID(), "kind": strings.ToLower(remote.Kind().String())})
|
||||
m.send(viewer.session, "Renegotiate", map[string]any{"streamId": p.streamID})
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
packet, _, readErr := remote.ReadRTP()
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
_ = local.WriteRTP(packet)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) onDisconnect(session *melody.Session) {
|
||||
if p := m.peer(session); p != nil {
|
||||
m.closePeer(p, "disconnected")
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
delete(m.observers, session)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) heartbeatLoop() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
now := time.Now().UnixMilli()
|
||||
m.mu.RLock()
|
||||
peers := make([]*peer, 0, len(m.peers))
|
||||
for _, p := range m.peers {
|
||||
peers = append(peers, p)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, p := range peers {
|
||||
if now-p.lastBeat.Load() >= (45 * time.Second).Milliseconds() {
|
||||
m.closePeer(p, "heartbeat_timeout")
|
||||
}
|
||||
}
|
||||
case <-m.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) closePeer(p *peer, reason string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.cleanup.Do(func() {
|
||||
if p.pc != nil {
|
||||
_ = p.pc.Close()
|
||||
}
|
||||
if p.role == "publisher" {
|
||||
_ = m.streams.UnregisterPublisher(context.Background(), p.streamID, p.userID)
|
||||
p.room.mu.Lock()
|
||||
tracks := make([]*publishedTrack, 0, len(p.room.tracks))
|
||||
for _, track := range p.room.tracks {
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
if p.room.publisher == p {
|
||||
p.room.publisher = nil
|
||||
}
|
||||
p.room.tracks = make(map[webrtc.RTPCodecType]*publishedTrack)
|
||||
viewers := make([]*peer, 0, len(p.room.viewers))
|
||||
for viewer := range p.room.viewers {
|
||||
viewers = append(viewers, viewer)
|
||||
}
|
||||
p.room.mu.Unlock()
|
||||
for _, viewer := range viewers {
|
||||
for _, track := range tracks {
|
||||
m.send(viewer.session, "ProducerClosed", map[string]any{"streamId": p.streamID, "producerId": track.id, "kind": strings.ToLower(track.kind.String()), "reason": reason})
|
||||
}
|
||||
m.send(viewer.session, "PublisherStatus", map[string]any{"streamId": p.streamID, "ready": false, "reason": reason})
|
||||
}
|
||||
} else if p.role == "subscriber" {
|
||||
_ = m.streams.UnregisterSubscriber(context.Background(), p.streamID, p.userID)
|
||||
p.room.mu.Lock()
|
||||
delete(p.room.viewers, p)
|
||||
publisher := p.room.publisher
|
||||
p.room.mu.Unlock()
|
||||
if publisher != nil {
|
||||
m.send(publisher.session, "SubscriberLeft", map[string]any{"streamId": p.streamID, "subscriberId": p.subscriberID})
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
delete(m.peers, p.session)
|
||||
p.room.mu.RLock()
|
||||
emptyRoom := p.room.publisher == nil && len(p.room.viewers) == 0
|
||||
p.room.mu.RUnlock()
|
||||
if emptyRoom {
|
||||
delete(m.rooms, p.streamID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.broadcastStreamList()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) getOrCreateRoom(streamID string) *room {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if r := m.rooms[streamID]; r != nil {
|
||||
return r
|
||||
}
|
||||
r := &room{streamID: streamID, viewers: make(map[*peer]struct{}), tracks: make(map[webrtc.RTPCodecType]*publishedTrack)}
|
||||
m.rooms[streamID] = r
|
||||
return r
|
||||
}
|
||||
|
||||
func (m *Manager) peer(session *melody.Session) *peer {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.peers[session]
|
||||
}
|
||||
|
||||
func (m *Manager) publisher(r *room) *peer {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.publisher
|
||||
}
|
||||
|
||||
func (m *Manager) trackSnapshot(r *room) []*publishedTrack {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
tracks := make([]*publishedTrack, 0, len(r.tracks))
|
||||
for _, track := range r.tracks {
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
func (m *Manager) sendStreamList(session *melody.Session) {
|
||||
streams, err := m.streams.List(context.Background())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(streams))
|
||||
for _, stream := range streams {
|
||||
items = append(items, map[string]any{"streamId": stream.StreamID, "publisherOnline": stream.PublisherOnline, "subscriberCount": stream.SubscriberCount})
|
||||
}
|
||||
m.send(session, "StreamListUpdate", map[string]any{"streams": items})
|
||||
}
|
||||
|
||||
func (m *Manager) broadcastStreamList() {
|
||||
m.mu.RLock()
|
||||
observers := make([]*melody.Session, 0, len(m.observers))
|
||||
for session := range m.observers {
|
||||
observers = append(observers, session)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, session := range observers {
|
||||
m.sendStreamList(session)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) sendPublisherStatus(streamID string, ready bool, reason string) {
|
||||
m.mu.RLock()
|
||||
peers := make([]*peer, 0, len(m.peers))
|
||||
for _, p := range m.peers {
|
||||
if p.role == "subscriber" && p.streamID == streamID {
|
||||
peers = append(peers, p)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, p := range peers {
|
||||
m.sendPublisherStatusTo(p.session, streamID, ready, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) sendPublisherStatusTo(session *melody.Session, streamID string, ready bool, reason string) {
|
||||
payload := map[string]any{"streamId": streamID, "ready": ready}
|
||||
if reason != "" {
|
||||
payload["reason"] = reason
|
||||
}
|
||||
m.send(session, "PublisherStatus", payload)
|
||||
}
|
||||
|
||||
func (m *Manager) send(session *melody.Session, msgType string, payload any) {
|
||||
encoded, err := json.Marshal(map[string]any{"type": msgType, "payload": payload})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = m.hub.Send(session, encoded)
|
||||
}
|
||||
|
||||
func (m *Manager) log(message string, args ...any) {
|
||||
if m.logger != nil {
|
||||
m.logger.Warn(message, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionUserID(session *melody.Session) (uint, bool) {
|
||||
if session == nil {
|
||||
return 0, false
|
||||
}
|
||||
value, ok := session.Keys["user_id"]
|
||||
if !ok || value == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case uint:
|
||||
return typed, typed > 0
|
||||
case uint64:
|
||||
return uint(typed), typed > 0
|
||||
case int:
|
||||
return uint(typed), typed > 0
|
||||
case int64:
|
||||
return uint(typed), typed > 0
|
||||
case string:
|
||||
parsed, err := strconv.ParseUint(typed, 10, 64)
|
||||
return uint(parsed), err == nil && parsed > 0
|
||||
default:
|
||||
parsed, err := strconv.ParseUint(fmt.Sprint(value), 10, 64)
|
||||
return uint(parsed), err == nil && parsed > 0
|
||||
}
|
||||
}
|
||||
|
||||
func peerStreamID(m *Manager, session *melody.Session) string {
|
||||
if p := m.peer(session); p != nil {
|
||||
return p.streamID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func errorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, bizlive.ErrMaxStreams):
|
||||
return "MAX_STREAMS"
|
||||
case errors.Is(err, bizlive.ErrPublisherExists):
|
||||
return "DUPLICATE_PUBLISHER"
|
||||
case errors.Is(err, bizlive.ErrSubscriberExists):
|
||||
return "DUPLICATE_SUBSCRIBER"
|
||||
default:
|
||||
return "INVALID_JOIN"
|
||||
}
|
||||
}
|
||||
|
||||
func drainRTCP(sender *webrtc.RTPSender) {
|
||||
for {
|
||||
if _, _, err := sender.ReadRTCP(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
bizgift "kra/internal/biz/gift"
|
||||
integrationbiz "kra/internal/biz/integration"
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/integration/cache"
|
||||
"kra/internal/integration/email"
|
||||
liveintegration "kra/internal/integration/live"
|
||||
mqintegration "kra/internal/integration/mq"
|
||||
paymentintegration "kra/internal/integration/payment"
|
||||
"kra/internal/integration/storage"
|
||||
@@ -36,5 +38,7 @@ var ProviderSet = wire.NewSet(
|
||||
wire.Bind(new(mq.SubscriptionRegistrar), new(*mqintegration.Reloadable)),
|
||||
websocketintegration.New,
|
||||
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
|
||||
liveintegration.ProviderSet,
|
||||
wire.Bind(new(bizgift.Notifier), new(*liveintegration.Manager)),
|
||||
wire.Bind(new(system.FileStorage), new(*storage.Reloadable)),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ package modules
|
||||
|
||||
import (
|
||||
integrationmodule "kra/internal/modules/integration"
|
||||
livemodule "kra/internal/modules/live"
|
||||
paymentmodule "kra/internal/modules/payment"
|
||||
systemmodule "kra/internal/modules/system"
|
||||
taskmodule "kra/internal/modules/task"
|
||||
@@ -20,5 +21,6 @@ func Catalog() module.Catalog {
|
||||
integrationmodule.Definition(),
|
||||
taskmodule.Definition(),
|
||||
paymentmodule.Definition(),
|
||||
livemodule.Definition(),
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -16,25 +16,25 @@ func TestCatalogMigrationIDsAreUnique(t *testing.T) {
|
||||
|
||||
func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
catalog := Catalog()
|
||||
if len(catalog.Definitions) != 4 {
|
||||
t.Fatalf("definitions = %d, want 4", len(catalog.Definitions))
|
||||
if len(catalog.Definitions) != 5 {
|
||||
t.Fatalf("definitions = %d, want 5", len(catalog.Definitions))
|
||||
}
|
||||
want := []string{"system", "integration", "task", "payment"}
|
||||
want := []string{"system", "integration", "task", "payment", "live"}
|
||||
for index, name := range want {
|
||||
if got := catalog.Definitions[index].Name; got != name {
|
||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||
}
|
||||
}
|
||||
if got := catalog.MigrationSteps(); len(got) != 13 {
|
||||
t.Fatalf("module migrations = %d, want 13", len(got))
|
||||
if got := catalog.MigrationSteps(); len(got) != 22 {
|
||||
t.Fatalf("module migrations = %d, want 22", len(got))
|
||||
}
|
||||
var menus, apis int
|
||||
for _, definition := range catalog.Definitions {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 3 || apis != 17 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 3/17", menus, apis)
|
||||
if menus != 11 || apis != 50 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
dataasset "kra/internal/data/asset"
|
||||
datagift "kra/internal/data/gift"
|
||||
datalive "kra/internal/data/live"
|
||||
datalivechat "kra/internal/data/livechat"
|
||||
datasetting "kra/internal/data/platformsetting"
|
||||
datarecharge "kra/internal/data/recharge"
|
||||
datasystem "kra/internal/data/system"
|
||||
"kra/pkg/database/migration"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
func Definition() module.Definition {
|
||||
root := module.Menu{Name: "liveOperations", Path: "liveOperations", Component: "view/routerHolder.vue", Title: "宇宙直播", Icon: "video-camera", Sort: 2}
|
||||
menus := []module.Menu{root, {Name: "liveRooms", Path: "liveRooms", ParentName: root.Name, Component: "modules/live/view/rooms.vue", Title: "直播间", Icon: "video-camera", Sort: 1}, {Name: "liveGifts", Path: "liveGifts", ParentName: root.Name, Component: "modules/live/view/gifts.vue", Title: "礼物管理", Icon: "present", Sort: 2}, {Name: "liveCategories", Path: "liveCategories", ParentName: root.Name, Component: "modules/live/view/categories.vue", Title: "分类管理", Icon: "price-tag", Sort: 3}, {Name: "liveOrders", Path: "liveOrders", ParentName: root.Name, Component: "modules/live/view/orders.vue", Title: "订单明细", Icon: "document", Sort: 4}, {Name: "liveSends", Path: "liveSends", ParentName: root.Name, Component: "modules/live/view/sends.vue", Title: "刷礼物明细", Icon: "promotion", Sort: 5}, {Name: "liveUsers", Path: "liveUsers", ParentName: root.Name, Component: "modules/live/view/users.vue", Title: "用户资产", Icon: "user", Sort: 6}, {Name: "liveSettings", Path: "liveSettings", ParentName: root.Name, Component: "modules/live/view/settings.vue", Title: "全站设置", Icon: "setting", Sort: 7}}
|
||||
apis := APIs()
|
||||
steps := append([]migration.Step{}, datalive.Migrations()...)
|
||||
steps = append(steps, datalivechat.Migrations()...)
|
||||
steps = append(steps, datagift.Migrations()...)
|
||||
steps = append(steps, datarecharge.Migrations()...)
|
||||
steps = append(steps, dataasset.Migrations()...)
|
||||
steps = append(steps, datasetting.Migrations()...)
|
||||
steps = append(steps, migration.Step{ID: "202609190006_live_admin_surface", Migrate: func(db *gorm.DB) error {
|
||||
var menuCount int64
|
||||
if err := db.Table("sys_base_menus").Count(&menuCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if menuCount == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := datasystem.EnsureAdminSurface(db, module.Surface{Menus: []module.Menu{root}}, 888); err != nil {
|
||||
return err
|
||||
}
|
||||
return datasystem.EnsureAdminSurface(db, module.Surface{Menus: menus[1:], APIs: apis}, 888)
|
||||
}})
|
||||
steps = append(steps, migration.Step{ID: "202609210001_remove_live_dashboard_menu", Migrate: func(db *gorm.DB) error {
|
||||
return datasystem.RemoveAdminMenus(db, "liveDashboard")
|
||||
}})
|
||||
return module.Definition{Name: "live", Migrations: steps, Surface: module.Surface{Menus: menus, APIs: apis}}
|
||||
}
|
||||
func APIs() []module.API {
|
||||
defs := []struct{ m, p, d string }{{"GET", "/live/admin/dashboard", "直播数据概览"}, {"GET", "/live/admin/categories", "查询直播分类"}, {"POST", "/live/admin/categories", "新建直播分类"}, {"PUT", "/live/admin/categories/:id", "更新直播分类"}, {"DELETE", "/live/admin/categories/:id", "删除直播分类"}, {"GET", "/live/admin/rooms", "查询直播间"}, {"GET", "/live/admin/rooms/:id", "查询直播间详情"}, {"POST", "/live/admin/rooms", "新建直播间"}, {"PUT", "/live/admin/rooms/:id", "更新直播间"}, {"DELETE", "/live/admin/rooms/:id", "删除直播间"}, {"PATCH", "/live/admin/rooms/:id/status", "启停直播间"}, {"PUT", "/live/admin/rooms/sort", "保存直播间排序"}, {"GET", "/live/admin/gifts", "查询礼物"}, {"GET", "/live/admin/gifts/:id", "查询礼物详情"}, {"POST", "/live/admin/gifts", "新建礼物"}, {"PUT", "/live/admin/gifts/:id", "更新礼物"}, {"DELETE", "/live/admin/gifts/:id", "删除礼物"}, {"PATCH", "/live/admin/gifts/:id/status", "上下架礼物"}, {"PUT", "/live/admin/gifts/sort", "保存礼物排序"}, {"GET", "/live/admin/sends", "查询打赏流水"}, {"GET", "/live/admin/orders", "查询充值订单"}, {"GET", "/live/admin/orders/:orderNo", "查询充值订单详情"}, {"PUT", "/live/admin/recharge/plans", "保存充值档位"}, {"GET", "/live/admin/users", "查询用户资产"}, {"GET", "/live/admin/users/:id", "查询用户详情"}, {"PATCH", "/live/admin/users/:id/status", "启停用户"}, {"POST", "/live/admin/users/:id/balance", "调整钻石余额"}, {"PUT", "/live/admin/settings", "保存全站设置"}, {"GET", "/live/client/me", "获取直播用户资料"}, {"PUT", "/live/client/me", "更新直播用户资料"}, {"PUT", "/live/client/me/password", "修改直播用户密码"}, {"POST", "/live/rooms/:id/gifts", "直播间送礼"}, {"POST", "/live/recharge/orders", "创建充值订单"}}
|
||||
out := make([]module.API, 0, len(defs))
|
||||
for _, v := range defs {
|
||||
out = append(out, module.API{Path: v.p, Method: v.m, Group: "宇宙直播", Description: v.d})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -84,6 +84,51 @@ var routes = map[string]routeValue{
|
||||
"GET /logViewer/content": {group: "文件日志", description: "分块读取日志文件内容"},
|
||||
"GET /logViewer/dates": {group: "文件日志", description: "获取存在日志的日期"},
|
||||
"GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"},
|
||||
"GET /api/streams": {group: "直播", description: "获取在线直播列表", public: true},
|
||||
"GET /live/streams": {group: "直播", description: "获取在线直播列表", public: true},
|
||||
"GET /live/streams/:streamId": {group: "直播", description: "获取直播间状态", public: true},
|
||||
"GET /live/categories": {group: "宇宙直播", description: "获取直播分类", public: true},
|
||||
"GET /live/rooms": {group: "宇宙直播", description: "获取直播间", public: true},
|
||||
"GET /live/rooms/:id": {group: "宇宙直播", description: "获取直播间详情", public: true},
|
||||
"GET /live/rooms/:id/messages": {group: "宇宙直播", description: "获取直播间聊天记录", public: true},
|
||||
"GET /live/gifts": {group: "宇宙直播", description: "获取礼物", public: true},
|
||||
"GET /live/recharge/plans": {group: "宇宙直播", description: "获取充值档位", public: true},
|
||||
"GET /live/settings": {group: "宇宙直播", description: "获取直播设置", public: true},
|
||||
"GET /live/client/me": {group: "宇宙直播", description: "获取直播用户资料"},
|
||||
"GET /live/admin/dashboard": {group: "宇宙直播", description: "直播数据概览"},
|
||||
"GET /live/admin/categories": {group: "宇宙直播", description: "查询直播分类"},
|
||||
"GET /live/admin/rooms": {group: "宇宙直播", description: "查询直播间"},
|
||||
"GET /live/admin/rooms/:id": {group: "宇宙直播", description: "查询直播间详情"},
|
||||
"GET /live/admin/gifts": {group: "宇宙直播", description: "查询礼物"},
|
||||
"GET /live/admin/gifts/:id": {group: "宇宙直播", description: "查询礼物详情"},
|
||||
"GET /live/admin/sends": {group: "宇宙直播", description: "查询打赏流水"},
|
||||
"GET /live/admin/orders": {group: "宇宙直播", description: "查询充值订单"},
|
||||
"GET /live/admin/orders/:orderNo": {group: "宇宙直播", description: "查询充值订单详情"},
|
||||
"GET /live/admin/users": {group: "宇宙直播", description: "查询用户资产"},
|
||||
"GET /live/admin/users/:id": {group: "宇宙直播", description: "查询用户详情"},
|
||||
"POST /live/admin/categories": {group: "宇宙直播", description: "新建直播分类", audit: true},
|
||||
"POST /live/admin/rooms": {group: "宇宙直播", description: "新建直播间", audit: true},
|
||||
"POST /live/admin/gifts": {group: "宇宙直播", description: "新建礼物", audit: true},
|
||||
"POST /live/admin/users/:id/balance": {group: "宇宙直播", description: "调整钻石余额", audit: true},
|
||||
"POST /live/rooms/:id/gifts": {group: "宇宙直播", description: "赠送礼物", audit: true},
|
||||
"POST /live/recharge/orders": {group: "宇宙直播", description: "创建充值订单", audit: true},
|
||||
"POST /live/client/auth/login": {group: "宇宙直播", description: "直播用户登录", public: true},
|
||||
"POST /live/client/auth/register": {group: "宇宙直播", description: "直播用户注册", public: true},
|
||||
"PUT /live/admin/categories/:id": {group: "宇宙直播", description: "更新直播分类", audit: true},
|
||||
"PUT /live/admin/rooms/:id": {group: "宇宙直播", description: "更新直播间", audit: true},
|
||||
"PUT /live/admin/rooms/sort": {group: "宇宙直播", description: "保存直播间排序", audit: true},
|
||||
"PUT /live/admin/gifts/:id": {group: "宇宙直播", description: "更新礼物", audit: true},
|
||||
"PUT /live/admin/gifts/sort": {group: "宇宙直播", description: "保存礼物排序", audit: true},
|
||||
"PUT /live/admin/recharge/plans": {group: "宇宙直播", description: "保存充值档位", audit: true},
|
||||
"PUT /live/admin/settings": {group: "宇宙直播", description: "保存直播设置", audit: true},
|
||||
"PUT /live/client/me": {group: "宇宙直播", description: "更新直播用户资料", audit: true},
|
||||
"PUT /live/client/me/password": {group: "宇宙直播", description: "修改直播用户密码", audit: true},
|
||||
"PATCH /live/admin/rooms/:id/status": {group: "宇宙直播", description: "启停直播间", audit: true},
|
||||
"PATCH /live/admin/gifts/:id/status": {group: "宇宙直播", description: "上下架礼物", audit: true},
|
||||
"PATCH /live/admin/users/:id/status": {group: "宇宙直播", description: "启停直播用户", audit: true},
|
||||
"DELETE /live/admin/categories/:id": {group: "宇宙直播", description: "删除直播分类", audit: true},
|
||||
"DELETE /live/admin/rooms/:id": {group: "宇宙直播", description: "删除直播间", audit: true},
|
||||
"DELETE /live/admin/gifts/:id": {group: "宇宙直播", description: "删除礼物", audit: true},
|
||||
"GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"},
|
||||
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
|
||||
"GET /payment/orders/summary": {group: "支付", description: "汇总支付订单运营指标"},
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -97,7 +98,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":196`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, fmt.Sprintf(`"route_count":%d`, len(engine.Routes()))) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
service "kra/internal/service/analytics"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Analytics struct{ s *service.Service }
|
||||
|
||||
func NewAnalytics(s *service.Service) *Analytics { return &Analytics{s: s} }
|
||||
func (h *Analytics) Dashboard(c *gin.Context) {
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "1"))
|
||||
v, e := h.s.Get(c.Request.Context(), days)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
service "kra/internal/service/asset"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type Asset struct{ s *service.Service }
|
||||
|
||||
func NewAsset(s *service.Service) *Asset { return &Asset{s: s} }
|
||||
func (h *Asset) List(c *gin.Context) {
|
||||
in, ok := bindQuery[dto.AssetUserListRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, total, e := h.s.List(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": v, "total": total})
|
||||
}
|
||||
func (h *Asset) Get(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.s.Get(c.Request.Context(), id)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Asset) Status(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.LiveStatusRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
actor := uint(0)
|
||||
if claims != nil {
|
||||
actor = claims.ID
|
||||
}
|
||||
if e := h.s.SetEnabled(c.Request.Context(), actor, id, in.Enabled); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
func (h *Asset) Adjust(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.AssetAdjustmentRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
actor := uint(0)
|
||||
if claims != nil {
|
||||
actor = claims.ID
|
||||
}
|
||||
v, e := h.s.Adjust(c.Request.Context(), actor, id, in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func businessID(c *gin.Context) (uint, bool) {
|
||||
v, e := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if e != nil || v == 0 {
|
||||
c.JSON(http.StatusBadRequest, Response{Code: CodeError, Msg: "参数无效"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(v), true
|
||||
}
|
||||
func bindQuery[T any](c *gin.Context) (*T, bool) {
|
||||
var v T
|
||||
if e := c.ShouldBindQuery(&v); e != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{Code: CodeError, Msg: "查询条件无效"})
|
||||
return nil, false
|
||||
}
|
||||
return &v, true
|
||||
}
|
||||
func bindJSON[T any](c *gin.Context) (*T, bool) {
|
||||
var v T
|
||||
if e := c.ShouldBindJSON(&v); e != nil {
|
||||
c.JSON(http.StatusBadRequest, Response{Code: CodeError, Msg: "提交内容无效"})
|
||||
return nil, false
|
||||
}
|
||||
return &v, true
|
||||
}
|
||||
func businessFail(c *gin.Context, e error) {
|
||||
c.JSON(http.StatusBadRequest, Response{Code: CodeError, Msg: e.Error()})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/gift"
|
||||
)
|
||||
|
||||
type Gift struct{ s *service.Service }
|
||||
|
||||
func NewGift(s *service.Service) *Gift { return &Gift{s: s} }
|
||||
func (h *Gift) List(c *gin.Context) {
|
||||
in, ok := bindQuery[dto.GiftListRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, total, e := h.s.List(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": v, "total": total})
|
||||
}
|
||||
func (h *Gift) PublicList(c *gin.Context) {
|
||||
enabled := true
|
||||
in := &dto.GiftListRequest{LiveListRequest: dto.LiveListRequest{Page: 1, PageSize: 100, OrderBy: "sort_asc"}, Enabled: &enabled}
|
||||
v, _, e := h.s.List(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": v})
|
||||
}
|
||||
func (h *Gift) Get(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.s.Get(c.Request.Context(), id)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Gift) Create(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.GiftRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.s.Save(c.Request.Context(), 0, in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Gift) Update(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.GiftRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.s.Save(c.Request.Context(), id, in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Gift) Delete(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.s.Delete(c.Request.Context(), id); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
func (h *Gift) Status(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.LiveStatusRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.s.SetEnabled(c.Request.Context(), id, in.Enabled); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
func (h *Gift) Sort(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.LiveSortRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.s.SaveSort(c.Request.Context(), in.Values); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
func (h *Gift) Sends(c *gin.Context) {
|
||||
in, ok := bindQuery[dto.GiftSendListRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, total, sum, e := h.s.ListSends(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": v, "total": total, "summary": sum})
|
||||
}
|
||||
func (h *Gift) Send(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.GiftSendRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
v, e := h.s.Send(c.Request.Context(), claims.ID, id, in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
"kra/internal/service/dto"
|
||||
liveservice "kra/internal/service/live"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Live struct {
|
||||
service *liveservice.StreamService
|
||||
catalog *liveservice.CatalogService
|
||||
}
|
||||
|
||||
func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService) *Live {
|
||||
return &Live{service: service, catalog: catalog}
|
||||
}
|
||||
|
||||
func (h *Live) List(c *gin.Context) {
|
||||
items, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
Fail(c, "直播列表获取失败")
|
||||
return
|
||||
}
|
||||
Write(c, CodeSuccess, gin.H{"streams": items}, "获取成功")
|
||||
}
|
||||
|
||||
func (h *Live) ListCategories(c *gin.Context) {
|
||||
in, ok := bindQuery[dto.LiveCategoryListRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, total, e := h.catalog.ListCategories(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items, "total": total})
|
||||
}
|
||||
func (h *Live) PublicCategories(c *gin.Context) {
|
||||
enabled := true
|
||||
in := &dto.LiveCategoryListRequest{LiveListRequest: dto.LiveListRequest{Page: 1, PageSize: 100, OrderBy: "sort_asc"}, Enabled: &enabled}
|
||||
items, _, e := h.catalog.ListCategories(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items})
|
||||
}
|
||||
func (h *Live) CreateCategory(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.LiveCategoryRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.catalog.CreateCategory(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Live) UpdateCategory(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.LiveCategoryRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.catalog.UpdateCategory(c.Request.Context(), id, in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Live) DeleteCategory(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.catalog.DeleteCategory(c.Request.Context(), id); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
func (h *Live) ListRooms(c *gin.Context) {
|
||||
in, ok := bindQuery[dto.LiveRoomListRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, total, e := h.catalog.ListRooms(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items, "total": total})
|
||||
}
|
||||
func (h *Live) PublicRooms(c *gin.Context) {
|
||||
in, ok := bindQuery[dto.LiveRoomListRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if in.Page < 1 {
|
||||
in.Page = 1
|
||||
}
|
||||
if in.PageSize < 1 {
|
||||
in.PageSize = 20
|
||||
}
|
||||
if in.OrderBy == "" {
|
||||
in.OrderBy = "sort_asc"
|
||||
}
|
||||
enabled := true
|
||||
in.Enabled = &enabled
|
||||
items, total, e := h.catalog.ListRooms(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items, "total": total, "page": in.Page, "pageSize": in.PageSize})
|
||||
}
|
||||
func (h *Live) GetRoom(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.catalog.GetRoom(c.Request.Context(), id)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Live) CreateRoom(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.LiveRoomRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.catalog.CreateRoom(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Live) UpdateRoom(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.LiveRoomRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.catalog.UpdateRoom(c.Request.Context(), id, in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Live) DeleteRoom(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.catalog.DeleteRoom(c.Request.Context(), id); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
func (h *Live) SetRoomStatus(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.LiveStatusRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.catalog.SetRoomEnabled(c.Request.Context(), id, in.Enabled); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
func (h *Live) SaveRoomSort(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.LiveSortRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.catalog.SaveRoomSort(c.Request.Context(), in.Values); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
|
||||
func (h *Live) LegacyList(c *gin.Context) {
|
||||
items, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "直播列表获取失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "streams": items})
|
||||
}
|
||||
|
||||
func (h *Live) Find(c *gin.Context) {
|
||||
item, err := h.service.Get(c.Request.Context(), c.Param("streamId"))
|
||||
if err != nil {
|
||||
if errors.Is(err, bizlive.ErrInvalidStreamID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "data": nil, "msg": "streamId 无效"})
|
||||
return
|
||||
}
|
||||
Fail(c, "直播间获取失败")
|
||||
return
|
||||
}
|
||||
Write(c, CodeSuccess, gin.H{"stream": item}, "获取成功")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/livechat"
|
||||
)
|
||||
|
||||
type LiveChat struct{ service *service.Service }
|
||||
|
||||
func NewLiveChat(service *service.Service) *LiveChat { return &LiveChat{service: service} }
|
||||
|
||||
func (h *LiveChat) History(c *gin.Context) {
|
||||
roomID, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindQuery[dto.LiveChatHistoryRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.History(c.Request.Context(), roomID, in.Limit)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/liveclient"
|
||||
)
|
||||
|
||||
type LiveClient struct{ service *service.Service }
|
||||
|
||||
func NewLiveClient(service *service.Service) *LiveClient { return &LiveClient{service: service} }
|
||||
|
||||
func (h *LiveClient) Register(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.LiveClientRegisterRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Register(c.Request.Context(), in, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
clientAuthFail(c, err)
|
||||
return
|
||||
}
|
||||
setClientToken(c, result)
|
||||
Write(c, CodeSuccess, result, "注册成功")
|
||||
}
|
||||
|
||||
func (h *LiveClient) Login(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.LiveClientLoginRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Login(c.Request.Context(), in, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
clientAuthFail(c, err)
|
||||
return
|
||||
}
|
||||
setClientToken(c, result)
|
||||
Write(c, CodeSuccess, result, "登录成功")
|
||||
}
|
||||
|
||||
func (h *LiveClient) Me(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Me(c.Request.Context(), claims.ID)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
|
||||
func (h *LiveClient) UpdateProfile(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.LiveClientProfileRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.UpdateProfile(c.Request.Context(), claims.ID, in)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
|
||||
func (h *LiveClient) ChangePassword(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
in, ok := bindJSON[dto.LiveClientPasswordRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.ChangePassword(c.Request.Context(), claims.ID, in, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
clientAuthFail(c, err)
|
||||
return
|
||||
}
|
||||
setClientToken(c, result)
|
||||
Write(c, CodeSuccess, result, "密码修改成功")
|
||||
}
|
||||
|
||||
func setClientToken(c *gin.Context, result *dto.LiveClientAuthResponse) {
|
||||
maxAge := int(time.UnixMilli(result.ExpiresAt).Unix() - time.Now().Unix())
|
||||
SetTokenCookie(c, result.Token, maxAge)
|
||||
}
|
||||
|
||||
func clientAuthFail(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, system.ErrAccountLocked):
|
||||
var locked *system.AccountLockedError
|
||||
errors.As(err, &locked)
|
||||
minutes := 0
|
||||
if locked != nil {
|
||||
minutes = locked.Minutes
|
||||
}
|
||||
Fail(c, "账号已锁定,请 "+strconv.Itoa(minutes)+" 分钟后再试")
|
||||
case errors.Is(err, system.ErrInvalidCredentials):
|
||||
Fail(c, "用户名不存在或者密码错误")
|
||||
case errors.Is(err, system.ErrUserDisabled):
|
||||
Fail(c, "用户被禁止登录")
|
||||
default:
|
||||
businessFail(c, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/platformsetting"
|
||||
)
|
||||
|
||||
type PlatformSetting struct{ s *service.Service }
|
||||
|
||||
func NewPlatformSetting(s *service.Service) *PlatformSetting { return &PlatformSetting{s: s} }
|
||||
func (h *PlatformSetting) Get(c *gin.Context) {
|
||||
v, e := h.s.Get(c.Request.Context())
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *PlatformSetting) Save(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.PlatformSetting](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.s.Save(c.Request.Context(), in); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession,
|
||||
NewIntegrationConfig,
|
||||
NewIntegrationConfig, NewLive, NewLiveChat, NewLiveClient,
|
||||
NewGift, NewRecharge, NewAsset, NewPlatformSetting, NewAnalytics,
|
||||
wire.Struct(new(Set), "*"),
|
||||
)
|
||||
|
||||
@@ -138,6 +138,7 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc {
|
||||
Write(c, CodeSuccess, gin.H{}, "自动创建数据库成功")
|
||||
return
|
||||
}
|
||||
slog.ErrorContext(c.Request.Context(), "数据库初始化失败", "mod", "system", "driver", input.DBType, "host", input.Host, "port", input.Port, "database", input.DBName, "error", err)
|
||||
Fail(c, "自动创建数据库失败,请查看后台日志,检查后在进行初始化")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/recharge"
|
||||
)
|
||||
|
||||
type Recharge struct{ s *service.Service }
|
||||
|
||||
func NewRecharge(s *service.Service) *Recharge { return &Recharge{s: s} }
|
||||
func (h *Recharge) Plans(c *gin.Context) {
|
||||
v, e := h.s.Plans(c.Request.Context())
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": v})
|
||||
}
|
||||
func (h *Recharge) SavePlans(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.RechargePlansRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := h.s.SavePlans(c.Request.Context(), in); e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OK(c)
|
||||
}
|
||||
func (h *Recharge) List(c *gin.Context) {
|
||||
in, ok := bindQuery[dto.RechargeOrderListRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, total, sum, e := h.s.List(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": v, "total": total, "summary": sum})
|
||||
}
|
||||
func (h *Recharge) Get(c *gin.Context) {
|
||||
v, e := h.s.Get(c.Request.Context(), c.Param("orderNo"))
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Recharge) Create(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.RechargeOrderRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
in.ClientIP = c.ClientIP()
|
||||
in.UserAgent = c.Request.UserAgent()
|
||||
in.DeviceID = c.GetHeader("X-Device-Id")
|
||||
v, e := h.s.Create(c.Request.Context(), claims.ID, in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
@@ -23,4 +23,12 @@ type Set struct {
|
||||
Navigation *Navigation
|
||||
Session *Session
|
||||
IntegrationConfig *IntegrationConfig
|
||||
Live *Live
|
||||
LiveChat *LiveChat
|
||||
LiveClient *LiveClient
|
||||
Gift *Gift
|
||||
Recharge *Recharge
|
||||
Asset *Asset
|
||||
PlatformSetting *PlatformSetting
|
||||
Analytics *Analytics
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterAnalytics(private *gin.RouterGroup, h *handler.Analytics) {
|
||||
private.GET("/live/admin/dashboard", h.Dashboard)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterAsset(private *gin.RouterGroup, h *handler.Asset) {
|
||||
private.GET("/live/admin/users", h.List)
|
||||
private.GET("/live/admin/users/:id", h.Get)
|
||||
private.PATCH("/live/admin/users/:id/status", h.Status)
|
||||
private.POST("/live/admin/users/:id/balance", h.Adjust)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterGift(public, private *gin.RouterGroup, h *handler.Gift) {
|
||||
public.GET("/live/gifts", h.PublicList)
|
||||
private.GET("/live/admin/gifts", h.List)
|
||||
private.GET("/live/admin/gifts/:id", h.Get)
|
||||
private.POST("/live/admin/gifts", h.Create)
|
||||
private.PUT("/live/admin/gifts/:id", h.Update)
|
||||
private.DELETE("/live/admin/gifts/:id", h.Delete)
|
||||
private.PATCH("/live/admin/gifts/:id/status", h.Status)
|
||||
private.PUT("/live/admin/gifts/sort", h.Sort)
|
||||
private.GET("/live/admin/sends", h.Sends)
|
||||
private.POST("/live/rooms/:id/gifts", h.Send)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterLive(public, private *gin.RouterGroup, h *handler.Live) {
|
||||
public.GET("/live/streams", h.List)
|
||||
public.GET("/live/streams/:streamId", h.Find)
|
||||
// Keep the old list endpoint as a compatibility alias for existing clients.
|
||||
public.GET("/api/streams", h.LegacyList)
|
||||
public.GET("/live/categories", h.PublicCategories)
|
||||
public.GET("/live/rooms", h.PublicRooms)
|
||||
public.GET("/live/rooms/:id", h.GetRoom)
|
||||
private.GET("/live/admin/categories", h.ListCategories)
|
||||
private.POST("/live/admin/categories", h.CreateCategory)
|
||||
private.PUT("/live/admin/categories/:id", h.UpdateCategory)
|
||||
private.DELETE("/live/admin/categories/:id", h.DeleteCategory)
|
||||
private.GET("/live/admin/rooms", h.ListRooms)
|
||||
private.GET("/live/admin/rooms/:id", h.GetRoom)
|
||||
private.POST("/live/admin/rooms", h.CreateRoom)
|
||||
private.PUT("/live/admin/rooms/:id", h.UpdateRoom)
|
||||
private.DELETE("/live/admin/rooms/:id", h.DeleteRoom)
|
||||
private.PATCH("/live/admin/rooms/:id/status", h.SetRoomStatus)
|
||||
private.PUT("/live/admin/rooms/sort", h.SaveRoomSort)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterLiveChat(public *gin.RouterGroup, h *handler.LiveChat) {
|
||||
public.GET("/live/rooms/:id/messages", h.History)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient) {
|
||||
public.POST("/live/client/auth/login", h.Login)
|
||||
public.POST("/live/client/auth/register", h.Register)
|
||||
private.GET("/live/client/me", h.Me)
|
||||
private.PUT("/live/client/me", h.UpdateProfile)
|
||||
private.PUT("/live/client/me/password", h.ChangePassword)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterPlatformSetting(public, private *gin.RouterGroup, h *handler.PlatformSetting) {
|
||||
public.GET("/live/settings", h.Get)
|
||||
private.PUT("/live/admin/settings", h.Save)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterRecharge(public, private *gin.RouterGroup, h *handler.Recharge) {
|
||||
public.GET("/live/recharge/plans", h.Plans)
|
||||
private.PUT("/live/admin/recharge/plans", h.SavePlans)
|
||||
private.GET("/live/admin/orders", h.List)
|
||||
private.GET("/live/admin/orders/:orderNo", h.Get)
|
||||
private.POST("/live/recharge/orders", h.Create)
|
||||
}
|
||||
@@ -42,6 +42,14 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
func() { RegisterEmail(private, r.handlers.Email) },
|
||||
func() { RegisterIntegrationConfig(private, r.handlers.IntegrationConfig) },
|
||||
func() { RegisterPayment(private, public, r.handlers.Payment) },
|
||||
func() { RegisterLive(public, private, r.handlers.Live) },
|
||||
func() { RegisterLiveChat(public, r.handlers.LiveChat) },
|
||||
func() { RegisterLiveClient(public, private, r.handlers.LiveClient) },
|
||||
func() { RegisterGift(public, private, r.handlers.Gift) },
|
||||
func() { RegisterRecharge(public, private, r.handlers.Recharge) },
|
||||
func() { RegisterAsset(private, r.handlers.Asset) },
|
||||
func() { RegisterPlatformSetting(public, private, r.handlers.PlatformSetting) },
|
||||
func() { RegisterAnalytics(private, r.handlers.Analytics) },
|
||||
}
|
||||
for _, register := range registrations {
|
||||
register()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
biz "kra/internal/biz/analytics"
|
||||
"kra/internal/service/dto"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct{ uc *biz.Usecase }
|
||||
|
||||
func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} }
|
||||
func (s *Service) Get(ctx context.Context, days int) (*dto.DashboardResponse, error) {
|
||||
if days != 0 && days != 1 && days != 7 && days != 30 {
|
||||
days = 1
|
||||
}
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
from, to := today.AddDate(0, 0, -days+1), now
|
||||
if days == 0 {
|
||||
from = today.AddDate(0, 0, -1)
|
||||
to = today.Add(-time.Nanosecond)
|
||||
}
|
||||
v, e := s.uc.Get(ctx, from, to)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out := &dto.DashboardResponse{RoomTotal: v.RoomTotal, RoomLive: v.RoomLive, RechargeCents: v.RechargeCents, PaidOrderCount: v.PaidOrderCount, GiftDiamonds: v.GiftDiamonds, GiftSendCount: v.GiftSendCount, DiamondRateCents: v.DiamondRateCents}
|
||||
for _, x := range v.RechargeTrend {
|
||||
out.RechargeTrend = append(out.RechargeTrend, dto.DashboardTrend{Date: x.Date, AmountCents: x.AmountCents})
|
||||
}
|
||||
for _, x := range v.GiftRanking {
|
||||
out.GiftRanking = append(out.GiftRanking, dto.DashboardRank{RoomID: x.RoomID, RoomName: x.RoomName, AnchorName: x.AnchorName, Diamonds: x.Diamonds})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package analytics
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewService)
|
||||
@@ -0,0 +1,68 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
biz "kra/internal/biz/asset"
|
||||
"kra/internal/service/dto"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct{ uc *biz.Usecase }
|
||||
|
||||
func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} }
|
||||
func dr(a, b string) []time.Time {
|
||||
if a == "" || b == "" {
|
||||
return nil
|
||||
}
|
||||
x, e1 := time.ParseInLocation("2006-01-02", a, time.Local)
|
||||
y, e2 := time.ParseInLocation("2006-01-02", b, time.Local)
|
||||
if e1 != nil || e2 != nil {
|
||||
return nil
|
||||
}
|
||||
return []time.Time{x, y.Add(24*time.Hour - time.Nanosecond)}
|
||||
}
|
||||
func (s *Service) List(ctx context.Context, in *dto.AssetUserListRequest) ([]*dto.AssetUserResponse, int64, error) {
|
||||
p, z := in.Page, in.PageSize
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if z < 1 {
|
||||
z = 10
|
||||
}
|
||||
items, total, e := s.uc.List(ctx, biz.Filter{Keyword: in.Keyword, UserID: in.UserID, Enabled: in.Enabled, CreatedAt: dr(in.StartedAt, in.EndedAt)}, biz.ListOptions{Offset: (p - 1) * z, Limit: z, OrderBy: in.OrderBy})
|
||||
if e != nil {
|
||||
return nil, 0, e
|
||||
}
|
||||
out := make([]*dto.AssetUserResponse, 0, len(items))
|
||||
for _, v := range items {
|
||||
out = append(out, user(v))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, id uint) (*dto.AssetUserResponse, error) {
|
||||
v, e := s.uc.Get(ctx, id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return user(v), nil
|
||||
}
|
||||
func (s *Service) SetEnabled(ctx context.Context, actor, id uint, enabled bool) error {
|
||||
return s.uc.SetEnabled(ctx, actor, id, enabled)
|
||||
}
|
||||
func (s *Service) Adjust(ctx context.Context, actor, id uint, in *dto.AssetAdjustmentRequest) (*dto.AssetUserResponse, error) {
|
||||
v, e := s.uc.Adjust(ctx, &biz.Adjustment{UserID: id, OperatorUserID: actor, Type: in.Type, Amount: in.Amount, Reason: in.Reason})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return user(v), nil
|
||||
}
|
||||
func user(v *biz.User) *dto.AssetUserResponse {
|
||||
out := &dto.AssetUserResponse{UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, Avatar: v.Avatar, Enabled: v.Enabled, DiamondBalance: v.DiamondBalance, TotalRechargeCents: v.TotalRechargeCents, TotalSpentDiamonds: v.TotalSpentDiamonds, CreatedAt: v.CreatedAt, LastLoginAt: v.LastLoginAt}
|
||||
for _, x := range v.RecentOrders {
|
||||
out.RecentOrders = append(out.RecentOrders, dto.AssetOrderRecord{OrderNo: x.OrderNo, Diamonds: x.Diamonds, AmountCents: x.AmountCents, Status: x.Status, CreatedAt: x.CreatedAt})
|
||||
}
|
||||
for _, x := range v.RecentSends {
|
||||
out.RecentSends = append(out.RecentSends, dto.AssetSendRecord{SerialNo: x.SerialNo, RoomName: x.RoomName, GiftName: x.GiftName, GiftIcon: x.GiftIcon, DiamondTotal: x.DiamondTotal, CreatedAt: x.CreatedAt})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package asset
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewService)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user