feat: live module and jenkins pipeline to harbor deploy

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