更新wz-admin和wz-api项目,调整环境变量配置以支持MongoDB连接,移除MySQL相关代码,确保API端口与前端一致,优化路由处理逻辑,提升系统性能与可维护性,同时更新文档以反映最新的架构变更。

This commit is contained in:
乘风
2026-04-30 14:53:27 +08:00
parent 260732bf41
commit e14d391676
39 changed files with 1328 additions and 1253 deletions

View File

@@ -1,6 +1,6 @@
# 开发推荐:删除本行或留空,让浏览器请求同源 /api/*,由 Vite 代理到 wz-api见 vite.config.ts默认 127.0.0.1:48080
# 若填写了完整地址,则浏览器直连该 API需 wz-api CORS 允许当前前端 Origin已默认含 http://localhost:40002
# VITE_API_BASE_URL=http://localhost:48080
# 开发推荐:删除本行或留空,让浏览器请求同源 /api/*,由 Vite 代理到 wz-api见 vite.config.ts默认 127.0.0.1:4040
# 若填写了完整地址,则浏览器直连该 API需 wz-api CORS 允许当前前端 Origin已默认含 http://localhost:4041
# VITE_API_BASE_URL=http://localhost:4040
# 联调开关:默认(不写或任意非 false即走 wz-api + MySQL/wz_documents。
# 仅当需要「纯前端 MOCK、不接后端」时设为 false

View File

@@ -4,10 +4,10 @@ import react from '@vitejs/plugin-react'
import { defineConfig, loadEnv } from 'vite'
// https://vite.dev/config/
// 与 new/wz-api .env.development 中 PORT=48080 一致;可建 .env.development 设 WZ_API_PROXY_TARGET 覆盖
// 与 new/wz-api PORT=4040.env.development / 生产)一致;可建 .env.development 设 WZ_API_PROXY_TARGET 覆盖
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const apiTarget = (env.WZ_API_PROXY_TARGET || 'http://127.0.0.1:48080').replace(/\/$/, '')
const apiTarget = (env.WZ_API_PROXY_TARGET || 'http://127.0.0.1:4040').replace(/\/$/, '')
return {
plugins: [react(), tailwindcss()],

View File

@@ -1,22 +1,28 @@
# wz-api 开发环境air 见 .air.toml先加载 .env 再叠加本文件)
# 键顺序与 .env.production 对齐。以下取值保留开发环境APP_ENV、PORT、GIN_MODE、APP_VERSION、
# DB_DSN、API_BASE_URL、CORS_ORIGINS勿把生产 MySQL 指到开发机进程)。
# MONGODB_URI、API_BASE_URL、CORS_ORIGINS勿把生产凭据指到错误环境)。
# 须设置 APP_ENV=development或在 shell 中 export 后再 go run
APP_ENV=development
# 与 new/wz-app 中 Vite 代理默认 48080 对齐;改端口请同步前端 WZ_API_PROXY_TARGET
PORT=48080
# 与生产 PORT=4040、master.py 默认 DEPLOY_API_PORT 一致;改端口请同步 wz-app / wz-admin 的 WZ_API_PROXY_TARGET
PORT=4040
GIN_MODE=debug
APP_VERSION=dev
# 开发不写 SKIP_PROD_SECRET_CHECK仅 GIN_MODE=release 时 config 会校验生产密钥)
# 必填MySQLGORM开发库 souldev生产见 .env.production 的 soul_miniprogram
DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True
# 必填MongoDB唯一业务库
# 本地 Docker 官方镜像多数默认「无认证」,请用下一行(库名 wz 不存在会在首次写入时自动出现):
MONGODB_URI=mongodb://127.0.0.1:27017/wz
# 若 compose 里配置了 root 密码示例MONGODB_URI=mongodb://root:你的密码@127.0.0.1:27017/wz?authSource=admin
# 若容器映射在非 27017 端口,改为对应端口即可。
# 连接云上 wzdj 用户时请改回(勿与无认证的本地实例混用同一串):
# MONGODB_URI=mongodb://wzdj:xxx@43.139.27.93:27017/wzdj?authSource=wzdj
# DB_DSN 已废弃,可删除或留空
# 可选:玩值 Mongo与生产同一实例与连接串业务仍以 MySQL 为准)
MONGODB_URI=mongodb://wzdj:NR6aLyyEaZKWZXky@43.139.27.93:27017/wzdj?authSource=wzdj
# 可选:遗留字段Mongo-only 构建不使用
# DB_DSN=
# 本服务对外可访问根 URL无尾斜杠支付/转账回调、H5 元数据等,缺省时由代码拼接)
API_BASE_URL=http://127.0.0.1:4040
@@ -24,9 +30,9 @@ API_BASE_URL=http://127.0.0.1:4040
# 仅在本地 Vite 端口不在代码默认可列表时追加(与 defaultCORSOrigins 合并)
CORS_ORIGINS=http://localhost:5175,http://127.0.0.1:5175
SKIP_AUTO_MIGRATE=1
# REDIS_URL=
# Mongo-only已无 MySQL AutoMigrate以下两项可忽略或删除
# 本地 Dockerredis-local-nwal 映射 6379:6379无密码用 db 0
REDIS_URL=redis://127.0.0.1:6379/2
# SYNC_ORDERS_INTERVAL_MINUTES=5
CRON_SECRET=

View File

@@ -0,0 +1,15 @@
# 本地 Docker Mongo官方镜像、未启用 --auth 时)
# 与仓库曾用的容器名一致,便于沿用脚本/习惯:
#
# docker stop wanzhi-mongodb 2>nul & docker rm wanzhi-mongodb 2>nul
# docker run -d --name wanzhi-mongodb -p 27017:27017 mongo:7
#
# 连接串(库名 wz与 seedmongo / wz-api 默认一致):
MONGODB_URI=mongodb://127.0.0.1:27017/wz
# 若使用 docker run -e MONGO_INITDB_ROOT_USERNAME=root -e MONGO_INITDB_ROOT_PASSWORD=secret
# MONGODB_URI=mongodb://root:secret@127.0.0.1:27017/wz?authSource=admin
# 初始化索引与演示数据:
# set MONGODB_URI=mongodb://127.0.0.1:27017/wz
# go run ./cmd/seedmongo

View File

@@ -11,21 +11,23 @@ APP_VERSION=0.0.0
# 上线前必去掉:用于预发/演练绕过 config.go 对默认密钥的校验(勿用于真实生产)
SKIP_PROD_SECRET_CHECK=staging
# 必填:生产 MySQL
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# 可选:玩值 Mongo与开发同一实例时可复用业务仍以 MySQL 为准)
# 必填:MongoDB唯一业务库
MONGODB_URI=mongodb://wzdj:NR6aLyyEaZKWZXky@43.139.27.93:27017/wzdj?authSource=wzdj
# 已废弃Mongo-only原 MySQL可留空或删除
# DB_DSN=
# 玩值 API 公网根地址(无尾斜杠;回调与 H5 依赖)
API_BASE_URL=https://wzapi.quwanzhi.com
# 可不写:代码内 defaultCORSOrigins 已含 https://wzadmin.quwanzhi.com 与 wzdj 等;若有额外源再追加
CORS_ORIGINS=https://wzadmin.quwanzhi.com,http://wzadmin.quwanzhi.com,https://wzdj.quwanzhi.com,http://wzdj.quwanzhi.com
SKIP_AUTO_MIGRATE=1
# Mongo-only无 MySQL AutoMigrate可忽略
# 本地 Dockerredis-local-nwal 映射 6379:6379无密码用 db 0
REDIS_ADDR=host.docker.internal:26738
REDIS_PASSWORD=WZX7HbnpZErnS6Tm
REDIS_DB=2
# REDIS_URL=
# SYNC_ORDERS_INTERVAL_MINUTES=5
CRON_SECRET=

View File

@@ -1,7 +1,15 @@
# wz-api 迁移说明
- **Go module** 已更名为 `wz-api`import 路径为 `wz-api/internal/...`
- **业务来源**:以仓库根 `old/app/api` 与《开发文档/玩值/迁移-API对照表.md》为真源当前代码体仍含 Soul 派对时期路由与模型,属**过渡态**。
- **新 C 端前缀**`/api/app/*` 已在 `router.Setup` 注册(现含 `GET /api/app/health`)。历史小程序路径 `/api/miniprogram/*` 保留至 wz-app 全量切换后再删
- **发版可观测**`GET /api/public/build-info` 返回 `buildTime``BUILD_TIME`)与可选 `gitCommit`(环境变量 **`GIT_COMMIT`**,服务端截为 ≤12 字符,未设则为空串),便于与前端/终端对照运行二进制
- **剥离步骤**:按域删除或停用 Soul 专有 handler → 按对照表实现玩值接口 → 更新 AutoMigrate 与 scripts
- **Mongo-only当前默认构建****不再连接 MySQL**。持久化仅此几类 Mongo 集合:
- **账号**`admin_users``app_users``user_auths``user_balances`C 端与玩值业务集合 `users` 分离)
- **玩值文档**:与 `internal/wzstore/collections.go` 同名集合;文档根字段含 **`ext_id`**,业务字段与历史 `body` 扁平一致,便于 `mongoexport` 风格导入
- **环境变量****`MONGODB_URI` 必填**(别名 `MONGO_URI`)。`DB_DSN` 已废弃,可省略
- **启动**`go run ./cmd/server`;数据初始化:`MONGODB_URI=... go run ./cmd/seedmongo`(管理员 `admin`/`admin123`;测试手机号 `13800138000`/`123456`)。
- **健康检查**`GET /health``database`/`mongo` 为 Mongo Ping 结果;`mysqlGone: true` 表示本构建不加载 MySQL。
- **路由范围(本构建)**:仅 **`/api/app/*`**、`/api/screen/*``/api/public/*`、管理端鉴权相关与 **`/api/admin/wz/*`**(另含 `RegisterWanzhiAdminRoutes` 的 mock 契约)。**已自默认路由移除**(未单独打包 legacy`/api/miniprogram/*``/api/db/*`、Soul 书籍/支付/提现等原依赖 MySQL 的全部分组;`/read/:id` H5 与 `/ws/miniprogram` 一并移除。
- **发版可观测**`GET /api/public/build-info` 返回 `buildTime``BUILD_TIME`)与可选 `gitCommit``GIT_COMMIT`≤12 字符)。
## 历史说明(对照)
- 业务来源仍以仓库 `old/app/api` 与《开发文档/玩值/迁移-API对照表.md》为溯源表中「已实现」接口在 Mongo-only 下**数据源已切换为 Mongo**,路径不变。

View File

@@ -0,0 +1,227 @@
// Seedmongo 创建索引并写入最小管理员、可选 C 端测试账号与玩值示例文档。
// 用法MONGODB_URI=mongodb://127.0.0.1:27017/wz go run ./cmd/seedmongo
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"golang.org/x/crypto/bcrypt"
)
func main() {
uri := strings.TrimSpace(os.Getenv("MONGODB_URI"))
if uri == "" {
uri = strings.TrimSpace(os.Getenv("MONGO_URI"))
}
if uri == "" {
log.Fatal("请设置 MONGODB_URI")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
log.Fatal(err)
}
defer func() { _ = client.Disconnect(context.Background()) }()
if err := client.Ping(ctx, nil); err != nil {
log.Fatal("ping: ", err)
}
db := client.Database(dbName(uri))
ensureIndexes(db)
seedAdmin(db)
seedAppUser(db)
seedPlayhubSample(db)
seedPlayhubExtended(db)
log.Println("seedmongo: 完成")
}
func dbName(uri string) string {
// mongodb://host/dbname
i := strings.LastIndex(uri, "/")
if i <= 0 || i == len(uri)-1 {
return "wz"
}
path := uri[i+1:]
if j := strings.IndexAny(path, "?"); j >= 0 {
path = path[:j]
}
if path == "" {
return "wz"
}
return path
}
func ensureIndexes(db *mongo.Database) {
ctx := context.Background()
_, _ = db.Collection("admin_users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "username", Value: 1}},
Options: options.Index().SetUnique(true),
})
_, _ = db.Collection("user_auths").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "auth_type", Value: 1}, {Key: "auth_id", Value: 1}},
Options: options.Index().SetUnique(true),
})
_, _ = db.Collection("app_users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "id", Value: 1}},
Options: options.Index().SetUnique(true),
})
_, _ = db.Collection("user_balances").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "user_id", Value: 1}},
Options: options.Index().SetUnique(true),
})
colls := []string{
"users", "streamers", "streamerBindings", "guilds", "stars", "esportsVenues", "venueBookings",
"starPartyRooms", "starPartyMembers", "liveRooms", "liveSessions", "liveComments", "liveGifts",
"liveShowcaseItems", "userJourneyEvents", "accountPawns", "transactions", "commissionLedger",
"productOrders", "companionOrders", "pointCardOrders", "rechargeOrders", "withdrawOrders",
"products", "contentItems", "gamePointCards", "gameAccounts", "levelingProducts", "levelingOrders",
"rfmRuleSets", "screenConfig", "games",
}
for _, c := range colls {
_, _ = db.Collection(c).Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "ext_id", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true),
})
}
}
func seedAdmin(db *mongo.Database) {
ctx := context.Background()
n, _ := db.Collection("admin_users").CountDocuments(ctx, bson.M{})
if n > 0 {
return
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err != nil {
log.Fatal(err)
}
now := time.Now().UTC()
_, err = db.Collection("admin_users").InsertOne(ctx, bson.M{
"id": uint32(1), "username": "admin", "password_hash": string(hash),
"role": "super_admin", "name": "卡若", "status": "active",
"created_at": now, "updated_at": now,
})
if err != nil {
log.Printf("admin_users seed: %v", err)
}
}
func seedAppUser(db *mongo.Database) {
ctx := context.Background()
n, _ := db.Collection("app_users").CountDocuments(ctx, bson.M{})
if n > 0 {
return
}
phone := "13800138000"
userID := "user_seed001"
hash, err := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
if err != nil {
log.Fatal(err)
}
now := time.Now().UTC()
_, _ = db.Collection("app_users").InsertOne(ctx, bson.M{
"id": userID, "nickname": "测试用户", "phone": phone,
"avatar": "", "created_at": now, "updated_at": now,
})
_, _ = db.Collection("user_auths").InsertOne(ctx, bson.M{
"user_id": userID, "auth_type": "phone", "auth_id": phone,
"credential": string(hash), "created_at": now, "updated_at": now,
})
_, _ = db.Collection("user_balances").InsertOne(ctx, bson.M{
"user_id": userID, "balance": 0.0, "created_at": now, "updated_at": now,
})
fmt.Println("seedmongo: 已插入测试账号 13800138000 / 123456")
}
func seedPlayhubSample(db *mongo.Database) {
ctx := context.Background()
n, _ := db.Collection("streamers").CountDocuments(ctx, bson.M{})
if n > 0 {
return
}
now := time.Now().UTC().Format(time.RFC3339)
ext := "0123456789abcdef01234567"
_, _ = db.Collection("streamers").InsertOne(ctx, bson.M{
"ext_id": ext, "name": "示例导师", "title": "王者荣耀", "game": "王者荣耀",
"status": "approved", "userId": "user_seed001", "fans": 1000,
"isLive": false, "createdAt": now, "updatedAt": now,
})
fmt.Println("seedmongo: 已插入示例 streamer", ext)
}
// seedPlayhubExtended 大屏、游戏、玩值 users 列表、公会等示例(各集合为空时才插入)
func seedPlayhubExtended(db *mongo.Database) {
ctx := context.Background()
now := time.Now().UTC().Format(time.RFC3339)
if n, _ := db.Collection("screenConfig").CountDocuments(ctx, bson.M{}); n == 0 {
_, _ = db.Collection("screenConfig").InsertOne(ctx, bson.M{
"ext_id": "default",
"title": "玩值电竞私域银行",
"subtitle": "电竞明星私域流量数字资产管理平台",
"updateInterval": 8000,
"theme": "dark",
"showQRCode": true,
"qrCodeUrl": "",
"yearOverYear": 15.2,
"monthOverMonth": 6.7,
"totalFans": 460000,
"championCount": 14,
"totalStars": 201,
"superIndividualCount": 1407,
"activeUserRate": 8.5,
"userValue": 413,
"createdAt": now,
"updatedAt": now,
})
fmt.Println("seedmongo: 已插入 screenConfig default")
}
if n, _ := db.Collection("games").CountDocuments(ctx, bson.M{}); n == 0 {
_, _ = db.Collection("games").InsertOne(ctx, bson.M{
"ext_id": "abcdef0123456789abcdef01",
"name": "王者荣耀",
"shortName": "王者",
"category": "moba",
"status": "active",
"sortOrder": 1.0,
"createdAt": now,
"updatedAt": now,
})
fmt.Println("seedmongo: 已插入示例 games")
}
if n, _ := db.Collection("users").CountDocuments(ctx, bson.M{}); n == 0 {
_, _ = db.Collection("users").InsertOne(ctx, bson.M{
"ext_id": "abcdef0123456789abcdef02",
"name": "玩值列表示例用户",
"phone": "13900139000",
"status": "active",
"isVip": false,
"balance": 100.0,
"createdAt": now,
"updatedAt": now,
})
fmt.Println("seedmongo: 已插入玩值 users 列表示例")
}
if n, _ := db.Collection("guilds").CountDocuments(ctx, bson.M{}); n == 0 {
_, _ = db.Collection("guilds").InsertOne(ctx, bson.M{
"ext_id": "abcdef0123456789abcdef03",
"name": "示例公会",
"status": "active",
"createdAt": now,
"updatedAt": now,
})
fmt.Println("seedmongo: 已插入示例 guilds")
}
}

View File

@@ -11,10 +11,10 @@ import (
"wz-api/internal/config"
"wz-api/internal/database"
"wz-api/internal/handler"
"wz-api/internal/redis"
"wz-api/internal/router"
"wz-api/internal/wechat"
"wz-api/internal/redis"
)
func main() {
@@ -24,16 +24,12 @@ func main() {
}
config.SetCurrent(cfg)
log.Printf("CORS 允许源(%d 个): %v", len(cfg.CORSOrigins), cfg.CORSOrigins)
if err := database.Init(cfg.DBDSN); err != nil {
log.Fatal("database: ", err)
}
if cfg.MongoURI != "" {
if err := database.InitMongo(cfg.MongoURI); err != nil {
log.Printf("mongodb: 连接失败已忽略MySQL 仍可用): %v", err)
} else {
defer database.CloseMongo()
}
if err := database.InitMongo(cfg.MongoURI); err != nil {
log.Fatal("mongodb: ", err)
}
defer database.CloseMongo()
_ = database.Init(cfg.DBDSN)
if err := wechat.Init(cfg); err != nil {
log.Fatal("wechat: ", err)
}
@@ -54,15 +50,6 @@ func main() {
Handler: r,
}
// 预热 Redis 缓存,避免首请求冷启动 502
go func() {
time.Sleep(2 * time.Second) // 等 DB 完全就绪
handler.WarmAllChaptersCache()
handler.WarmBookPartsCache()
handler.WarmConfigCache()
handler.WarmLatestChaptersCache()
}()
go func() {
log.Printf("wz-api listen on :%s (mode=%s)", cfg.Port, cfg.Mode)
log.Printf(" -> 访问地址: http://localhost:%s (健康检查: http://localhost:%s/health)", cfg.Port, cfg.Port)
@@ -74,28 +61,6 @@ func main() {
}
}()
// 内置订单对账定时任务SYNC_ORDERS_INTERVAL_MINUTES > 0 时启动)
if cfg.SyncOrdersIntervalMinutes > 0 {
interval := time.Duration(cfg.SyncOrdersIntervalMinutes) * time.Minute
go func() {
// 启动后延迟 1 分钟执行第一次,避免与启动流程抢资源
time.Sleep(1 * time.Minute)
ticker := time.NewTicker(interval)
defer ticker.Stop()
handler.SyncOrdersLogf("内置定时任务已启动,间隔 %d 分钟", cfg.SyncOrdersIntervalMinutes)
for {
ctx := context.Background()
synced, total, err := handler.RunSyncOrders(ctx, 7)
if err != nil {
handler.SyncOrdersLogf("对账失败: %v", err)
} else if total > 0 {
handler.SyncOrdersLogf("本轮检查 %d 笔,补齐 %d 笔漏单", total, synced)
}
<-ticker.C
}
}()
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

View File

@@ -17,7 +17,6 @@ require (
golang.org/x/crypto v0.47.0
golang.org/x/time v0.8.0
gorm.io/datatypes v1.2.5
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
)
@@ -38,6 +37,7 @@ require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
)
require (

View File

@@ -15,7 +15,7 @@ import (
type Config struct {
Port string
Mode string
DBDSN string
DBDSN string // 已废弃Mongo-only保留字段避免旧配置引用编译错误
TrustedProxies []string
CORSOrigins []string
Version string // APP_VERSION打包/部署前写在 .env/health 返回
@@ -68,8 +68,8 @@ type Config struct {
// Redis 连接地址(如 redis://localhost:6379/0空表示不使用 Redis
RedisURL string
// MongoDB(可选):与玩值旧栈 / 脚本迁移数据源对齐;当前业务读写仍以 MySQL(GORM) 为主
MongoURI string // MONGODB_URI,非空则启动时连接并 Ping
// MongoDB:唯一业务持久化;别名 MONGO_URI
MongoURI string // MONGODB_URI 必填
}
// BaseURLJoin 将路径拼接到 BaseURLpath 应以 / 开头
@@ -212,9 +212,6 @@ func Load() (*Config, error) {
mode = "debug"
}
dsn := os.Getenv("DB_DSN")
if dsn == "" {
log.Fatal("DB_DSN 未配置,请在 .env 中配置 MySQL 连接串例如DB_DSN=user:pass@tcp(xxx.gz.cdb.myqcloud.com:3306)/dbname?charset=utf8mb4&parseTime=True")
}
version := os.Getenv("APP_VERSION")
if version == "" {
@@ -323,11 +320,14 @@ func Load() (*Config, error) {
// RedisREDIS_URL 配置后启用;不配置则跳过。本地开发可设 REDIS_URL=redis://localhost:6379/0
redisURL := strings.TrimSpace(os.Getenv("REDIS_URL"))
// MongoDB可选;别名 MONGO_URI
// MongoDB必填
mongoURI := strings.TrimSpace(os.Getenv("MONGODB_URI"))
if mongoURI == "" {
mongoURI = strings.TrimSpace(os.Getenv("MONGO_URI"))
}
if mongoURI == "" {
log.Fatal("MONGODB_URI 未配置Mongo-only 构建需要 Mongo 连接串例如MONGODB_URI=mongodb://127.0.0.1:27017/wz")
}
// H5 /read/:id 由 wz-api 本机提供,公网须与 API 同域或反代;默认与 API_BASE_URL 一致(避免前端站无 /read/ 路由导致 404
h5ReadPublic := strings.TrimSpace(os.Getenv("H5_READ_PUBLIC_BASE"))

View File

@@ -1,334 +1,15 @@
package database
import (
"log"
"os"
"strconv"
"strings"
"time"
import "gorm.io/gorm"
"wz-api/internal/model"
var nilDB *gorm.DB
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var db *gorm.DB
// Init 使用 DSN 连接 MySQL供 handler 通过 DB() 使用
// Init MySQL 已移除Mongo-only 构建下始终返回 nil保留符号供遗留代码编译。
func Init(dsn string) error {
// 慢查询阈值:默认 5 秒,避免 GORM 默认 200ms 导致控制台刷屏;可通过 SLOW_SQL_THRESHOLD_MS 覆盖
slowMs := 5000
if s := os.Getenv("SLOW_SQL_THRESHOLD_MS"); s != "" {
if n, e := strconv.Atoi(s); e == nil && n > 0 {
slowMs = n
}
}
gormLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Duration(slowMs) * time.Millisecond,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gormLogger})
if err != nil {
return err
}
// 连接池配置:防止腾讯云 CDB 等云数据库在空闲后主动断开长连接导致 "unexpected EOF"
sqlDB, err := db.DB()
if err != nil {
return err
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute) // 短于 MySQL wait_timeout避免用到已关闭的连接
sqlDB.SetConnMaxIdleTime(2 * time.Minute) // 空闲连接超过此时间自动关闭并重建
skipMigrate := strings.ToLower(strings.TrimSpace(os.Getenv("SKIP_AUTO_MIGRATE")))
if skipMigrate == "1" || skipMigrate == "true" || skipMigrate == "yes" {
log.Println("database: SKIP_AUTO_MIGRATE enabled, skipping schema migration")
// 即使跳过 AutoMigrate也补齐关键运行时字段避免新功能因历史库缺列直接报错。
ensurePersonSchema(db)
ensureCkbLeadSchema(db)
ensureLinkTagSchema(db)
// 开放平台 API Key / 请求日志表:全量 AutoMigrate 被跳过时仍需建表,否则管理端报 1146
if err := db.AutoMigrate(&model.OpenPlatformApiKey{}, &model.OpenPlatformApiLog{}); err != nil {
log.Printf("database: open_platform tables migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserAuth{}); err != nil {
log.Printf("database: user_auths migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.WzDocument{}); err != nil {
log.Printf("database: wz_documents migrate warning: %v", err)
}
ensureOpenPlatformTablesRaw(db)
ensureUserDiscPdpColumns(db)
log.Println("database: connected")
return nil
}
if err := db.AutoMigrate(&model.WechatCallbackLog{}); err != nil {
log.Printf("database: wechat_callback_logs migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Withdrawal{}); err != nil {
log.Printf("database: withdrawals migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.MatchRecord{}); err != nil {
log.Printf("database: match_records migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserAddress{}); err != nil {
log.Printf("database: user_addresses migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.VipRole{}); err != nil {
log.Printf("database: vip_roles migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Order{}); err != nil {
log.Printf("database: orders migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserBalance{}); err != nil {
log.Printf("database: user_balances migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserAuth{}); err != nil {
log.Printf("database: user_auths migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.BalanceTransaction{}); err != nil {
log.Printf("database: balance_transactions migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Mentor{}); err != nil {
log.Printf("database: mentors migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil {
log.Printf("database: mentor_consultations migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.AuthorConfig{}); err != nil {
log.Printf("database: author_config migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.AdminUser{}); err != nil {
log.Printf("database: admin_users migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.CkbLeadRecord{}); err != nil {
log.Printf("database: ckb_lead_records migrate warning: %v", err)
}
ensureCkbLeadSchema(db)
if err := db.AutoMigrate(&model.Person{}); err != nil {
log.Printf("database: persons migrate warning: %v", err)
}
// persons 历史库可能因旧索引冲突导致 AutoMigrate 中断,补一层列级自愈,避免 /api/db/persons 报 Unknown column。
ensurePersonSchema(db)
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
log.Printf("database: link_tags migrate warning: %v", err)
}
ensureLinkTagSchema(db)
// 以下表业务大量使用,必须参与 AutoMigrate否则旧库缺字段会导致订单/用户/VIP 等接口报错
if err := db.AutoMigrate(&model.User{}); err != nil {
log.Printf("database: users migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.SystemConfig{}); err != nil {
log.Printf("database: system_config migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Chapter{}); err != nil {
log.Printf("database: chapters migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Book{}); err != nil {
log.Printf("database: books migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.GiftPayRequest{}); err != nil {
log.Printf("database: gift_pay_requests migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
log.Printf("database: user_rules migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserTrack{}); err != nil {
log.Printf("database: user_tracks migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserRuleCompletion{}); err != nil {
log.Printf("database: user_rule_completions migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.OpenPlatformApiKey{}); err != nil {
log.Printf("database: open_platform_api_keys migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.OpenPlatformApiLog{}); err != nil {
log.Printf("database: open_platform_api_logs migrate warning: %v", err)
}
ensureOpenPlatformTablesRaw(db)
ensureUserDiscPdpColumns(db)
if err := db.AutoMigrate(&model.WzDocument{}); err != nil {
log.Printf("database: wz_documents migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}
// ensureUserDiscPdpColumns users 表补充 disc/pdpSKIP_AUTO_MIGRATE 时 User 未跑 AutoMigrate 时需手工列)
func ensureUserDiscPdpColumns(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.User{}, "disc") {
if err := db.Exec("ALTER TABLE users ADD COLUMN disc VARCHAR(64) NULL COMMENT 'DISC 测评'").Error; err != nil {
log.Printf("database: users add disc column warning: %v", err)
}
}
if !m.HasColumn(&model.User{}, "pdp") {
if err := db.Exec("ALTER TABLE users ADD COLUMN pdp VARCHAR(64) NULL COMMENT 'PDP 测评'").Error; err != nil {
log.Printf("database: users add pdp column warning: %v", err)
}
}
}
// DB 返回全局 *gorm.DB仅在 Init 成功后调用
// DB 返回 nil请勿在未迁移的路由中调用已由 router 精简移除)。
func DB() *gorm.DB {
return db
}
// ensureOpenPlatformTablesRaw AutoMigrate 失败或旧库无表时,用 CREATE IF NOT EXISTS 兜底(与 model 字段一致)
func ensureOpenPlatformTablesRaw(db *gorm.DB) {
m := db.Migrator()
if m.HasTable(&model.OpenPlatformApiKey{}) && m.HasTable(&model.OpenPlatformApiLog{}) {
return
}
sqlKeys := `
CREATE TABLE IF NOT EXISTS open_platform_api_keys (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(120) NOT NULL DEFAULT '',
key_prefix VARCHAR(48) NOT NULL,
key_hash VARCHAR(64) NOT NULL,
revoked_at DATETIME(3) NULL,
created_at DATETIME(3) NULL,
updated_at DATETIME(3) NULL,
PRIMARY KEY (id),
KEY idx_open_platform_api_keys_revoked_at (revoked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
sqlLogs := `
CREATE TABLE IF NOT EXISTS open_platform_api_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
api_key_id BIGINT UNSIGNED NOT NULL,
key_prefix VARCHAR(48) NOT NULL DEFAULT '',
method VARCHAR(16) NOT NULL DEFAULT '',
path VARCHAR(512) NOT NULL DEFAULT '',
status_code INT NOT NULL DEFAULT 0,
client_ip VARCHAR(64) NOT NULL DEFAULT '',
duration_ms INT NOT NULL DEFAULT 0,
request_body TEXT NULL,
response_body TEXT NULL,
created_at DATETIME(3) NULL,
PRIMARY KEY (id),
KEY idx_open_platform_api_logs_api_key_id (api_key_id),
KEY idx_open_platform_api_logs_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
if !m.HasTable(&model.OpenPlatformApiKey{}) {
if err := db.Exec(sqlKeys).Error; err != nil {
log.Printf("database: ensureOpenPlatformTablesRaw open_platform_api_keys warning: %v", err)
}
}
if !m.HasTable(&model.OpenPlatformApiLog{}) {
if err := db.Exec(sqlLogs).Error; err != nil {
log.Printf("database: ensureOpenPlatformTablesRaw open_platform_api_logs warning: %v", err)
}
}
}
func ensurePersonSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.Person{}, "is_pinned") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '置顶到小程序首页'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add is_pinned", err)
}
}
if !m.HasColumn(&model.Person{}, "person_source") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN person_source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源:空=后台手工vip_sync=超级个体同步'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add person_source", err)
}
}
if !m.HasIndex(&model.Person{}, "idx_persons_is_pinned") {
if err := db.Exec("CREATE INDEX idx_persons_is_pinned ON persons(is_pinned)").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=create idx_persons_is_pinned", err)
}
}
if !m.HasColumn(&model.Person{}, "home_entry_config") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN home_entry_config TEXT NULL COMMENT '首页入口配置(打赏/上麦JSON'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add home_entry_config", err)
}
}
}
func ensureCkbLeadSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.CkbLeadRecord{}, "push_status") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN push_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '推送状态: pending/success/failed'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add push_status", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "action") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN action VARCHAR(20) NOT NULL DEFAULT 'lead' COMMENT '记录类型: lead/join/match'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add action", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "plan_api_key") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN plan_api_key VARCHAR(100) NOT NULL DEFAULT '' COMMENT '本次命中的获客计划 apiKey 快照'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add plan_api_key", err)
}
}
if !m.HasIndex(&model.CkbLeadRecord{}, "idx_ckb_lead_action") {
if err := db.Exec("CREATE INDEX idx_ckb_lead_action ON ckb_lead_records(action)").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=create idx_ckb_lead_action", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "ckb_code") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN ckb_code INT NOT NULL DEFAULT 0 COMMENT '存客宝响应 code快照'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add ckb_code", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "ckb_message") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN ckb_message VARCHAR(500) NOT NULL DEFAULT '' COMMENT '存客宝响应 message快照'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add ckb_message", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "ckb_data") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN ckb_data TEXT NULL COMMENT '存客宝响应 data快照 JSON'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add ckb_data", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "retry_count") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add retry_count", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "last_push_at") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN last_push_at DATETIME NULL COMMENT '最后推送时间'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add last_push_at", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "next_retry_at") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN next_retry_at DATETIME NULL COMMENT '下次重试时间'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add next_retry_at", err)
}
}
if !m.HasIndex(&model.CkbLeadRecord{}, "idx_ckb_lead_push_status") {
if err := db.Exec("CREATE INDEX idx_ckb_lead_push_status ON ckb_lead_records(push_status)").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=create idx_ckb_lead_push_status", err)
}
}
// 放宽 push_status 长度以容纳存客宝细粒度状态pending_verify、expired 等)
if err := db.Exec("ALTER TABLE ckb_lead_records MODIFY COLUMN push_status VARCHAR(64) NOT NULL DEFAULT 'pending' COMMENT '推送状态'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=widen push_status", err)
}
}
func ensureLinkTagSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.LinkTag{}, "pass_phone") {
if err := db.Exec("ALTER TABLE link_tags ADD COLUMN pass_phone TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否透传手机号'").Error; err != nil {
log.Printf("database: link_tags schema ensure warning: %v; action=add pass_phone", err)
}
}
if !m.HasColumn(&model.LinkTag{}, "phone_param_name") {
if err := db.Exec("ALTER TABLE link_tags ADD COLUMN phone_param_name VARCHAR(64) NOT NULL DEFAULT 'phone' COMMENT '手机号透传参数名'").Error; err != nil {
log.Printf("database: link_tags schema ensure warning: %v; action=add phone_param_name", err)
}
}
return nilDB
}

View File

@@ -2,7 +2,10 @@ package database
import (
"context"
"fmt"
"log"
"net/url"
"strings"
"time"
"go.mongodb.org/mongo-driver/mongo"
@@ -10,11 +13,20 @@ import (
)
var mongoClient *mongo.Client
var mongoDB *mongo.Database
// InitMongo 在非空 URI 时连接 MongoDB 并 Ping失败返回 error。业务主库仍为 MySQL。
func dbNameFromURI(uri string) string {
u, err := url.Parse(uri)
if err != nil || u.Path == "" || u.Path == "/" {
return "wz"
}
return strings.TrimPrefix(u.Path, "/")
}
// InitMongo 连接 MongoDB 为唯一业务库URI 为空返回 error。
func InitMongo(uri string) error {
if uri == "" {
return nil
if strings.TrimSpace(uri) == "" {
return fmt.Errorf("MONGODB_URI 未配置")
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
@@ -30,15 +42,21 @@ func InitMongo(uri string) error {
return err
}
mongoClient = c
log.Println("mongodb: 已连接并 Ping 成功")
mongoDB = c.Database(dbNameFromURI(uri))
log.Println("mongodb: 已连接并 Ping 成功,数据库:", dbNameFromURI(uri))
return nil
}
// MongoClient 返回已初始化的客户端;未配置或非空 URI 连接失败时为 nil
// MongoClient 返回已初始化的客户端。
func MongoClient() *mongo.Client {
return mongoClient
}
// MongoDatabase 返回当前业务库;须在 InitMongo 成功之后调用。
func MongoDatabase() *mongo.Database {
return mongoDB
}
// CloseMongo 优雅断开 Mongo。
func CloseMongo() {
if mongoClient == nil {
@@ -50,4 +68,5 @@ func CloseMongo() {
log.Printf("mongodb: disconnect: %v", err)
}
mongoClient = nil
mongoDB = nil
}

View File

@@ -1,16 +1,17 @@
package handler
import (
"errors"
"net/http"
"wz-api/internal/auth"
"wz-api/internal/config"
"wz-api/internal/database"
"wz-api/internal/model"
mongostore "wz-api/internal/store/mongo"
"github.com/gin-gonic/gin"
mongodriver "go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// AdminCheck GET /api/admin 鉴权检查JWTAuthorization Bearer 或 Cookie已登录返回 success 或概览占位
@@ -43,7 +44,7 @@ func AdminCheck(c *gin.Context) {
})
}
// AdminLogin POST /api/admin 登录(优先校验 admin_users 表,表空时回退 ADMIN_USERNAME/PASSWORD 并自动初始化
// AdminLogin POST /api/admin 登录admin_users;空库且 env 账号匹配时可创建首条 super_admin
func AdminLogin(c *gin.Context) {
cfg := config.Get()
if cfg == nil {
@@ -60,12 +61,9 @@ func AdminLogin(c *gin.Context) {
}
username := trimSpace(body.Username)
password := body.Password
db := database.DB()
// 1. 尝试从 admin_users 表校验
var u model.AdminUser
err := db.Where("username = ?", username).First(&u).Error
if err == nil {
u, err := mongostore.FindAdminByUsername(username)
if err == nil && u != nil {
if u.Status != "active" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "账号已禁用"})
return
@@ -87,8 +85,7 @@ func AdminLogin(c *gin.Context) {
return
}
// 2. 表内无匹配:若表为空且 env 账号正确,则创建初始 super_admin 并登录
if err != gorm.ErrRecordNotFound {
if !errors.Is(err, mongodriver.ErrNoDocuments) && err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "系统错误"})
return
}
@@ -100,9 +97,8 @@ func AdminLogin(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
// 表为空时初始化超级管理员
var cnt int64
if db.Model(&model.AdminUser{}).Count(&cnt).Error != nil || cnt > 0 {
cnt, err := mongostore.CountAdminUsers()
if err != nil || cnt > 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
@@ -111,14 +107,14 @@ func AdminLogin(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
return
}
initial := model.AdminUser{
initial := &model.AdminUser{
Username: cfg.AdminUsername,
PasswordHash: string(hash),
Role: "super_admin",
Name: "卡若",
Status: "active",
}
if err := db.Create(&initial).Error; err != nil {
if err := mongostore.InsertAdmin(initial); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
return
}

View File

@@ -11,7 +11,6 @@ import (
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// playhubUnifiedOrder 与 old AdminOrder 对齐
@@ -27,11 +26,11 @@ type playhubUnifiedOrder struct {
SourceTable string `json:"sourceTable"`
}
func playhubCollectUnifiedOrders(db *gorm.DB) []playhubUnifiedOrder {
func playhubCollectUnifiedOrders() []playhubUnifiedOrder {
now := time.Now().UTC().Format(time.RFC3339)
var out []playhubUnifiedOrder
addFrom := func(coll, typ, itemName string, mapFn func(map[string]interface{}, string) playhubUnifiedOrder) {
rows, err := wzstore.List(db, coll, 500)
rows, err := wzstore.List(coll, 500)
if err != nil {
return
}
@@ -197,12 +196,12 @@ func playhubTime(v interface{}, def string) string {
return def
}
func playhubDashboardPayload(db *gorm.DB) gin.H {
users, _ := wzstore.List(db, wzstore.CollUsers, 5000)
streamers, _ := wzstore.List(db, wzstore.CollStreamers, 2000)
pawns, _ := wzstore.List(db, wzstore.CollAccountPawns, 2000)
txs, _ := wzstore.List(db, wzstore.CollTransactions, 500)
orders := playhubCollectUnifiedOrders(db)
func playhubDashboardPayload() gin.H {
users, _ := wzstore.List(wzstore.CollUsers, 5000)
streamers, _ := wzstore.List(wzstore.CollStreamers, 2000)
pawns, _ := wzstore.List(wzstore.CollAccountPawns, 2000)
txs, _ := wzstore.List(wzstore.CollTransactions, 500)
orders := playhubCollectUnifiedOrders()
var totalRevenue float64
for _, r := range txs {
@@ -305,12 +304,12 @@ func playhubRecentOrdersSlice(orders []playhubUnifiedOrder) []gin.H {
return out
}
func playhubScreenDashboardStats(db *gorm.DB) gin.H {
u, _ := wzstore.List(db, wzstore.CollUsers, 5000)
s, _ := wzstore.List(db, wzstore.CollStreamers, 2000)
p, _ := wzstore.List(db, wzstore.CollAccountPawns, 2000)
tx, _ := wzstore.List(db, wzstore.CollTransactions, 500)
orders := playhubCollectUnifiedOrders(db)
func playhubScreenDashboardStats() gin.H {
u, _ := wzstore.List(wzstore.CollUsers, 5000)
s, _ := wzstore.List(wzstore.CollStreamers, 2000)
p, _ := wzstore.List(wzstore.CollAccountPawns, 2000)
tx, _ := wzstore.List(wzstore.CollTransactions, 500)
orders := playhubCollectUnifiedOrders()
var totalRevenue float64
for _, r := range tx {

View File

@@ -7,7 +7,6 @@ import (
"strings"
"time"
"wz-api/internal/database"
"wz-api/internal/model"
"wz-api/internal/wzstore"
@@ -15,8 +14,7 @@ import (
)
func wzAdminJSONList(c *gin.Context, coll string, limit int) {
db := database.DB()
rows, err := wzstore.List(db, coll, limit)
rows, err := wzstore.List(coll, limit)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
@@ -33,8 +31,7 @@ func wzAdminJSONGet(c *gin.Context, coll, id string) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
return
}
db := database.DB()
d, err := wzstore.Get(db, coll, id)
d, err := wzstore.Get(coll, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "未找到", "message": "未找到"})
return
@@ -50,8 +47,7 @@ func WzAdminVenueByID(c *gin.Context) { wzAdminJSONGet(c, wzstore.CollEsportsVen
// WzAdminUsers 玩值 C 端用户wz_documents users
func WzAdminUsers(c *gin.Context) {
db := database.DB()
rows, err := wzstore.List(db, wzstore.CollUsers, 500)
rows, err := wzstore.List(wzstore.CollUsers, 500)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "data": []interface{}{}})
return
@@ -84,7 +80,7 @@ func WzAdminUserPatch(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"})
return
}
if err := wzstore.UpsertByExtID(database.DB(), wzstore.CollUsers, id, body); err != nil {
if err := wzstore.UpsertByExtID(wzstore.CollUsers, id, body); err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "用户不存在"})
return
}
@@ -93,7 +89,7 @@ func WzAdminUserPatch(c *gin.Context) {
// WzAdminUserDelete DELETE /api/admin/wz/users/:id
func WzAdminUserDelete(c *gin.Context) {
if err := wzstore.Delete(database.DB(), wzstore.CollUsers, c.Param("id")); err != nil {
if err := wzstore.Delete(wzstore.CollUsers, c.Param("id")); err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "用户不存在"})
return
}
@@ -142,7 +138,7 @@ func WzAdminStreamers(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"})
return
}
id, err := wzstore.Insert(database.DB(), wzstore.CollStreamers, body)
id, err := wzstore.Insert(wzstore.CollStreamers, body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "新增失败"})
return
@@ -150,8 +146,7 @@ func WzAdminStreamers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"id": id}})
return
}
db := database.DB()
rows, err := wzstore.List(db, wzstore.CollStreamers, 500)
rows, err := wzstore.List(wzstore.CollStreamers, 500)
if err != nil || len(rows) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubLytiaoSeed()})
return
@@ -162,17 +157,14 @@ func WzAdminStreamers(c *gin.Context) {
guildName := interface{}(nil)
gid := playhubStr(m["guildId"])
if gid != "" {
if g, e := wzstore.Get(db, wzstore.CollGuilds, gid); e == nil {
if g, e := wzstore.Get(wzstore.CollGuilds, gid); e == nil {
gm := playhubDocMap(g)
guildName = playhubStr(gm["name"])
}
}
m["id"] = r.ExtID
m["guildName"] = guildName
var bindCnt int64
db.Model(&model.WzDocument{}).Where("coll = ?", wzstore.CollStreamerBindings).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.streamerId')) = ?", r.ExtID).
Count(&bindCnt)
bindCnt, _ := wzstore.CountBindingsForStreamer(r.ExtID)
m["bindingCount"] = bindCnt
out = append(out, m)
}
@@ -193,8 +185,7 @@ func playhubLytiaoSeed() []gin.H {
// WzAdminStreamerGet GET
func WzAdminStreamerGet(c *gin.Context) {
id := c.Param("id")
db := database.DB()
d, err := wzstore.Get(db, wzstore.CollStreamers, id)
d, err := wzstore.Get(wzstore.CollStreamers, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "主播不存在"})
return
@@ -203,16 +194,13 @@ func WzAdminStreamerGet(c *gin.Context) {
guildName := interface{}(nil)
gid := playhubStr(m["guildId"])
if gid != "" {
if g, e := wzstore.Get(db, wzstore.CollGuilds, gid); e == nil {
if g, e := wzstore.Get(wzstore.CollGuilds, gid); e == nil {
guildName = playhubStr(playhubDocMap(g)["name"])
}
}
m["id"] = d.ExtID
m["guildName"] = guildName
var cnt int64
db.Model(&model.WzDocument{}).Where("coll = ?", wzstore.CollStreamerBindings).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.streamerId')) = ?", id).
Count(&cnt)
cnt, _ := wzstore.CountBindingsForStreamer(id)
m["bindingCount"] = cnt
m["bindingActiveCount"] = cnt
m["bindingExpiredCount"] = 0
@@ -227,7 +215,7 @@ func WzAdminStreamerPatch(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"})
return
}
if err := wzstore.UpsertByExtID(database.DB(), wzstore.CollStreamers, id, body); err != nil {
if err := wzstore.UpsertByExtID(wzstore.CollStreamers, id, body); err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "主播不存在"})
return
}
@@ -236,7 +224,7 @@ func WzAdminStreamerPatch(c *gin.Context) {
// WzAdminStreamerDelete DELETE
func WzAdminStreamerDelete(c *gin.Context) {
if err := wzstore.Delete(database.DB(), wzstore.CollStreamers, c.Param("id")); err != nil {
if err := wzstore.Delete(wzstore.CollStreamers, c.Param("id")); err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "主播不存在"})
return
}
@@ -246,11 +234,11 @@ func WzAdminStreamerDelete(c *gin.Context) {
// WzAdminStreamerBindings GET /api/admin/wz/streamers/:id/bindings
func WzAdminStreamerBindings(c *gin.Context) {
sid := c.Param("id")
db := database.DB()
var rows []model.WzDocument
_ = db.Where("coll = ?", wzstore.CollStreamerBindings).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.streamerId')) = ?", sid).
Find(&rows).Error
rows, err := wzstore.ListBindingsForStreamer(sid)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
out := make([]interface{}, 0, len(rows))
for i := range rows {
out = append(out, wzstore.ToResponse(&rows[i]))
@@ -265,12 +253,11 @@ func WzAdminSeedScreen(c *gin.Context) {
// WzAdminDashboard GET
func WzAdminDashboard(c *gin.Context) {
db := database.DB()
if _, err := wzstore.List(db, wzstore.CollUsers, 1); err != nil {
if _, err := wzstore.List(wzstore.CollUsers, 1); err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubDashboardMock()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubDashboardPayload(db)})
c.JSON(http.StatusOK, gin.H{"success": true, "data": playhubDashboardPayload()})
}
func playhubDashboardMock() gin.H {
@@ -286,10 +273,9 @@ func playhubDashboardMock() gin.H {
// WzAdminFinance GET
func WzAdminFinance(c *gin.Context) {
db := database.DB()
tx, _ := wzstore.List(db, wzstore.CollTransactions, 300)
rc, _ := wzstore.List(db, wzstore.CollRechargeOrders, 200)
wd, _ := wzstore.List(db, wzstore.CollWithdrawOrders, 200)
tx, _ := wzstore.List(wzstore.CollTransactions, 300)
rc, _ := wzstore.List(wzstore.CollRechargeOrders, 200)
wd, _ := wzstore.List(wzstore.CollWithdrawOrders, 200)
mapRows := func(rows []model.WzDocument) []interface{} {
o := make([]interface{}, 0, len(rows))
for i := range rows {
@@ -309,7 +295,7 @@ func WzAdminFinance(c *gin.Context) {
// WzAdminOrders GET unified
func WzAdminOrders(c *gin.Context) {
orders := playhubCollectUnifiedOrders(database.DB())
orders := playhubCollectUnifiedOrders()
c.JSON(http.StatusOK, gin.H{"success": true, "data": orders})
}
@@ -321,7 +307,7 @@ func WzAdminOrderDetail(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id 或 sourceTable"})
return
}
d, err := wzstore.Get(database.DB(), tab, id)
d, err := wzstore.Get(tab, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "订单不存在"})
return
@@ -342,8 +328,7 @@ func WzAdminOrderStatus(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 id / sourceTable / status"})
return
}
db := database.DB()
if err := wzstore.UpdateJSONPartial(db, body.SourceTable, body.ID, map[string]interface{}{"status": body.Status}); err != nil {
if err := wzstore.UpdateJSONPartial(body.SourceTable, body.ID, map[string]interface{}{"status": body.Status}); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "更新失败"})
return
}
@@ -352,8 +337,6 @@ func WzAdminOrderStatus(c *gin.Context) {
// WzAdminUserValueSummary GET
func WzAdminUserValueSummary(c *gin.Context) {
db := database.DB()
_ = db
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "占位汇总:接入业务指标后可替换", "stats": gin.H{}}})
}
@@ -382,7 +365,7 @@ func WzAdminMallImport(c *gin.Context) {
"category": "peripheral", "price": 0, "status": "draft", "source": src,
"createdAt": now, "updatedAt": now,
}
id, _ := wzstore.Insert(database.DB(), wzstore.CollProducts, doc)
id, _ := wzstore.Insert(wzstore.CollProducts, doc)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已创建占位商品","inserted": 1, "id": id})
}
@@ -393,7 +376,7 @@ func WzAdminSyncFulu(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "message": "请配置 FULU_APP_KEY 与 FULU_APP_SECRET", "code": "missing_config"})
return
}
n, _ := wzstore.Count(database.DB(), wzstore.CollGamePointCards)
n, _ := wzstore.Count(wzstore.CollGamePointCards)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "福禄同步接口已就绪", "currentCardsCount": n})
}

View File

@@ -6,8 +6,6 @@ import (
"wz-api/internal/auth"
"wz-api/internal/config"
"wz-api/internal/database"
"wz-api/internal/model"
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
@@ -42,7 +40,6 @@ func WzAppAnalyticsJourney(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未登录时需传 anonymousIdH5 匿名会话)"})
return
}
db := database.DB()
now := time.Now().UTC()
inserted := 0
deduped := 0
@@ -60,11 +57,7 @@ func WzAppAnalyticsJourney(c *gin.Context) {
invalid++
continue
}
var cnt int64
db.Model(&model.WzDocument{}).Where("coll = ?", wzstore.CollUserJourneyEvents).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.sessionId')) = ?", sessionID).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.clientEventId')) = ?", clientID).
Count(&cnt)
cnt, _ := wzstore.CountJourneyDedup(sessionID, clientID)
if cnt > 0 {
deduped++
continue
@@ -92,7 +85,7 @@ func WzAppAnalyticsJourney(c *gin.Context) {
if anon != "" {
doc["anonymousId"] = anon
}
if _, err := wzstore.Insert(db, wzstore.CollUserJourneyEvents, doc); err != nil {
if _, err := wzstore.Insert(wzstore.CollUserJourneyEvents, doc); err != nil {
deduped++
continue
}

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"sort"
"wz-api/internal/database"
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
@@ -12,8 +11,7 @@ import (
// WzAppStreamersList GET /api/app/streamers
func WzAppStreamersList(c *gin.Context) {
db := database.DB()
rows, err := wzstore.List(db, wzstore.CollStreamers, 200)
rows, err := wzstore.List(wzstore.CollStreamers, 200)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
@@ -60,8 +58,7 @@ func WzAppStreamerByID(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 id"})
return
}
db := database.DB()
d, err := wzstore.Get(db, wzstore.CollStreamers, id)
d, err := wzstore.Get(wzstore.CollStreamers, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到"})
return
@@ -71,8 +68,7 @@ func WzAppStreamerByID(c *gin.Context) {
// WzAppGamesList GET /api/app/games
func WzAppGamesList(c *gin.Context) {
db := database.DB()
rows, err := wzstore.List(db, wzstore.CollGames, 50)
rows, err := wzstore.List(wzstore.CollGames, 50)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "数据库未连接或未初始化"})
return

View File

@@ -6,7 +6,6 @@ import (
"strings"
"time"
"wz-api/internal/database"
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
@@ -14,8 +13,7 @@ import (
// WzAppMallPointCardsList GET /api/app/mall/point-cards — C 端可售点卡目录(不暴露内部全量 body
func WzAppMallPointCardsList(c *gin.Context) {
db := database.DB()
rows, err := wzstore.List(db, wzstore.CollGamePointCards, 500)
rows, err := wzstore.List(wzstore.CollGamePointCards, 500)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "服务暂不可用"})
return
@@ -70,8 +68,7 @@ func WzAppMallPointCardByID(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效商品 id"})
return
}
db := database.DB()
d, err := wzstore.Get(db, wzstore.CollGamePointCards, cardID)
d, err := wzstore.Get(wzstore.CollGamePointCards, cardID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "商品不存在"})
return
@@ -115,8 +112,7 @@ func WzAppOrderPointCardPOST(c *gin.Context) {
if qty > 99 {
qty = 99
}
db := database.DB()
d, err := wzstore.Get(db, wzstore.CollGamePointCards, cardID)
d, err := wzstore.Get(wzstore.CollGamePointCards, cardID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "商品不存在"})
return
@@ -155,7 +151,7 @@ func WzAppOrderPointCardPOST(c *gin.Context) {
"skuName": playhubStr(m["name"]),
"createdAt": time.Now().UTC().Format(time.RFC3339),
}
extID, err := wzstore.Insert(db, wzstore.CollPointCardOrders, doc)
extID, err := wzstore.Insert(wzstore.CollPointCardOrders, doc)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "创建订单失败"})
return

View File

@@ -7,8 +7,8 @@ import (
"strings"
"time"
"wz-api/internal/database"
"wz-api/internal/model"
mongostore "wz-api/internal/store/mongo"
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
@@ -29,14 +29,13 @@ func WzAppLiveContext(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效主播 id"})
return
}
db := database.DB()
st, err := wzstore.Get(db, wzstore.CollStreamers, sid)
st, err := wzstore.Get(wzstore.CollStreamers, sid)
if err != nil || playhubStr(playhubDocMap(st)["status"]) != "approved" {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "主播不存在或未审核"})
return
}
sm := playhubDocMap(st)
room, _ := wzstore.FindLiveRoomByStreamerID(db, sid)
room, _ := wzstore.FindLiveRoomByStreamerID(sid)
var roomData interface{} = nil
if room != nil {
rm := playhubDocMap(room)
@@ -51,12 +50,12 @@ func WzAppLiveContext(c *gin.Context) {
guildID := playhubStr(sm["guildId"])
var guildData interface{} = nil
if guildID != "" && playhubIsHexID(guildID) {
if g, e := wzstore.Get(db, wzstore.CollGuilds, guildID); e == nil {
if g, e := wzstore.Get(wzstore.CollGuilds, guildID); e == nil {
gm := playhubDocMap(g)
guildData = gin.H{"id": g.ExtID, "name": playhubStr(gm["name"])}
}
}
showRows, _ := wzstore.List(db, wzstore.CollLiveShowcaseItems, 50)
showRows, _ := wzstore.List(wzstore.CollLiveShowcaseItems, 50)
var showcase []interface{}
for _, r := range showRows {
m := playhubDocMap(&r)
@@ -82,13 +81,12 @@ func WzAppLiveCommentsGET(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效主播 id"})
return
}
db := database.DB()
st, err := wzstore.Get(db, wzstore.CollStreamers, sid)
st, err := wzstore.Get(wzstore.CollStreamers, sid)
if err != nil || playhubStr(playhubDocMap(st)["status"]) != "approved" {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "主播不存在或未审核"})
return
}
rows, _ := wzstore.List(db, wzstore.CollLiveComments, 400)
rows, _ := wzstore.List(wzstore.CollLiveComments, 400)
var forSid []model.WzDocument
for i := range rows {
m := playhubDocMap(&rows[i])
@@ -139,8 +137,7 @@ func WzAppLiveCommentsPOST(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效主播 id"})
return
}
db := database.DB()
st, err := wzstore.Get(db, wzstore.CollStreamers, sid)
st, err := wzstore.Get(wzstore.CollStreamers, sid)
if err != nil || playhubStr(playhubDocMap(st)["status"]) != "approved" {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "主播不存在或未审核"})
return
@@ -169,7 +166,7 @@ func WzAppLiveCommentsPOST(c *gin.Context) {
return
}
userName := playhubResolveUserName(uid)
room, _ := wzstore.FindLiveRoomByStreamerID(db, sid)
room, _ := wzstore.FindLiveRoomByStreamerID(sid)
liveRoomID := ""
if room != nil {
liveRoomID = room.ExtID
@@ -183,7 +180,7 @@ func WzAppLiveCommentsPOST(c *gin.Context) {
"type": typ,
"createdAt": time.Now().UTC().Format(time.RFC3339),
}
eid, err := wzstore.Insert(db, wzstore.CollLiveComments, doc)
eid, err := wzstore.Insert(wzstore.CollLiveComments, doc)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败"})
return
@@ -192,16 +189,7 @@ func WzAppLiveCommentsPOST(c *gin.Context) {
}
func playhubResolveUserName(uid string) string {
var u model.User
if err := database.DB().Where("id = ?", uid).First(&u).Error; err == nil {
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
return strings.TrimSpace(*u.Nickname)
}
if u.Phone != nil && strings.TrimSpace(*u.Phone) != "" {
return strings.TrimSpace(*u.Phone)
}
}
return "用户"
return mongostore.AppUserDisplayName(uid)
}
// WzAppLiveGift POST /api/app/live/gift — 简化:记录礼物并更新主播礼物计数(完整分账后续可扩展)
@@ -226,7 +214,6 @@ func WzAppLiveGift(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效主播"})
return
}
db := database.DB()
orderNo := fmt.Sprintf("GIFT%d%s", time.Now().UnixNano(), sid[:6])
price := body.Amount
if price <= 0 {
@@ -240,16 +227,16 @@ func WzAppLiveGift(c *gin.Context) {
"orderNo": orderNo,
"createdAt": time.Now().UTC().Format(time.RFC3339),
}
if _, err := wzstore.Insert(db, wzstore.CollLiveGifts, gdoc); err != nil {
if _, err := wzstore.Insert(wzstore.CollLiveGifts, gdoc); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "送礼失败"})
return
}
st, err := wzstore.Get(db, wzstore.CollStreamers, sid)
st, err := wzstore.Get(wzstore.CollStreamers, sid)
if err == nil {
m := playhubDocMap(st)
tg := playhubFloat(m["totalGifts"]) + price
ti := playhubFloat(m["totalIncome"]) + price*0.5
_ = wzstore.UpdateJSONPartial(db, wzstore.CollStreamers, sid, map[string]interface{}{"totalGifts": tg, "totalIncome": ti})
_ = wzstore.UpdateJSONPartial(wzstore.CollStreamers, sid, map[string]interface{}{"totalGifts": tg, "totalIncome": ti})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -280,8 +267,7 @@ func WzAppStreamerLivePOST(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "action 须为 start 或 stop"})
return
}
db := database.DB()
st, err := wzstore.FindApprovedStreamerForUser(db, uid)
st, err := wzstore.FindApprovedStreamerForUser(uid)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "未找到已审核的主播档案"})
return
@@ -302,7 +288,7 @@ func WzAppStreamerLivePOST(c *gin.Context) {
pull = ""
}
}
_ = wzstore.UpdateJSONPartial(db, wzstore.CollStreamers, st.ExtID, map[string]interface{}{
_ = wzstore.UpdateJSONPartial(wzstore.CollStreamers, st.ExtID, map[string]interface{}{
"isLive": true, "title": title, "updatedAt": now,
})
roomBody := map[string]interface{}{
@@ -317,17 +303,17 @@ func WzAppStreamerLivePOST(c *gin.Context) {
"createdAt": now,
}
// upsert room: try find by streamer
if r, e := wzstore.FindLiveRoomByStreamerID(db, st.ExtID); e == nil {
_ = wzstore.UpsertByExtID(db, wzstore.CollLiveRooms, r.ExtID, roomBody)
if r, e := wzstore.FindLiveRoomByStreamerID(st.ExtID); e == nil {
_ = wzstore.UpsertByExtID(wzstore.CollLiveRooms, r.ExtID, roomBody)
} else {
_, _ = wzstore.Insert(db, wzstore.CollLiveRooms, roomBody)
_, _ = wzstore.Insert(wzstore.CollLiveRooms, roomBody)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"action": "start"}})
return
}
_ = wzstore.UpdateJSONPartial(db, wzstore.CollStreamers, st.ExtID, map[string]interface{}{"isLive": false, "updatedAt": now})
if r, e := wzstore.FindLiveRoomByStreamerID(db, st.ExtID); e == nil {
_ = wzstore.UpdateJSONPartial(db, wzstore.CollLiveRooms, r.ExtID, map[string]interface{}{"status": "offline", "updatedAt": now})
_ = wzstore.UpdateJSONPartial(wzstore.CollStreamers, st.ExtID, map[string]interface{}{"isLive": false, "updatedAt": now})
if r, e := wzstore.FindLiveRoomByStreamerID(st.ExtID); e == nil {
_ = wzstore.UpdateJSONPartial(wzstore.CollLiveRooms, r.ExtID, map[string]interface{}{"status": "offline", "updatedAt": now})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"action": "stop"}})
}
@@ -344,8 +330,7 @@ func WzAppStreamerLiveCloudGET(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "vendor 须为 tencent|aliyun|qiniu|volcengine|generic"})
return
}
db := database.DB()
st, err := wzstore.FindApprovedStreamerForUser(db, uid)
st, err := wzstore.FindApprovedStreamerForUser(uid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "未找到已审核的主播档案"})
return
@@ -370,8 +355,7 @@ func WzAppAudienceInsights(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
db := database.DB()
me, err := wzstore.FindApprovedStreamerForUser(db, uid)
me, err := wzstore.FindApprovedStreamerForUser(uid)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "非主播或未审核"})
return
@@ -383,7 +367,7 @@ func WzAppAudienceInsights(c *gin.Context) {
return
}
streamerID := mySid
evs, _ := wzstore.List(db, wzstore.CollUserJourneyEvents, 4000)
evs, _ := wzstore.List(wzstore.CollUserJourneyEvents, 4000)
since := time.Now().Add(-7 * 24 * time.Hour)
var rows []gin.H
for _, r := range evs {

View File

@@ -3,7 +3,6 @@ package handler
import (
"net/http"
"wz-api/internal/database"
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
@@ -13,8 +12,7 @@ const screenConfigExtID = "default"
// WzScreenConfigGET GET /api/screen/config — 与 old 一致:根级返回(无 success 包裹)
func WzScreenConfigGET(c *gin.Context) {
db := database.DB()
d, err := wzstore.Get(db, wzstore.CollScreenConfig, screenConfigExtID)
d, err := wzstore.Get(wzstore.CollScreenConfig, screenConfigExtID)
defaultCfg := defaultScreenConfigMap()
if err != nil {
c.JSON(http.StatusOK, defaultCfg)
@@ -43,8 +41,7 @@ func WzScreenConfigPOST(c *gin.Context) {
}
delete(body, "_id")
body["id"] = screenConfigExtID
db := database.DB()
_ = wzstore.UpsertByExtID(db, wzstore.CollScreenConfig, screenConfigExtID, body)
_ = wzstore.UpsertByExtID(wzstore.CollScreenConfig, screenConfigExtID, body)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -70,14 +67,12 @@ func defaultScreenConfigMap() map[string]interface{} {
// WzScreenDataDashboard GET /api/screen/data/dashboard
func WzScreenDataDashboard(c *gin.Context) {
db := database.DB()
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"stats": playhubScreenDashboardStats(db)}})
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"stats": playhubScreenDashboardStats()}})
}
// WzScreenStreamersGET GET /api/screen/streamers
func WzScreenStreamersGET(c *gin.Context) {
db := database.DB()
rows, err := wzstore.List(db, wzstore.CollStreamers, 500)
rows, err := wzstore.List(wzstore.CollStreamers, 500)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "data": []interface{}{}})
return

View File

@@ -7,12 +7,10 @@ import (
"strings"
"time"
"wz-api/internal/database"
"wz-api/internal/model"
"wz-api/internal/wzstore"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// WzAppStreamerMe GET /api/app/streamer/me
@@ -22,11 +20,10 @@ func WzAppStreamerMe(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
db := database.DB()
st, err := wzstore.FindApprovedStreamerForUser(db, uid)
st, err := wzstore.FindApprovedStreamerForUser(uid)
if err != nil && errors.Is(err, wzstore.ErrNotFound) &&
(os.Getenv("PLAYHUB_STREAMER_AUTOPROVISION") == "1" || os.Getenv("STREAMER_DEV_AUTOPROVISION") == "1") {
st = playhubTryProvisionStreamer(db, uid)
st = playhubTryProvisionStreamer(uid)
err = nil
}
if st == nil {
@@ -34,7 +31,7 @@ func WzAppStreamerMe(c *gin.Context) {
return
}
sm, _ := wzstore.BodyMap(st)
room, _ := wzstore.FindLiveRoomByStreamerID(db, st.ExtID)
room, _ := wzstore.FindLiveRoomByStreamerID(st.ExtID)
var roomOut interface{}
if room != nil {
rm, _ := wzstore.BodyMap(room)
@@ -65,32 +62,32 @@ func playhubBool(v interface{}) bool {
return b
}
func playhubTryProvisionStreamer(db *gorm.DB, uid string) *model.WzDocument {
func playhubTryProvisionStreamer(uid string) *model.WzDocument {
now := time.Now().UTC().Format(time.RFC3339)
body := map[string]interface{}{
"userId": uid,
"name": "主播",
"title": "开发环境 · 自动过审主播",
"game": "英雄联盟",
"gameId": 2,
"avatar": "",
"coverImage": "/lol-stream.jpg",
"tags": []string{"dev", "auto-approved"},
"level": 1,
"fans": 100,
"hotValue": 1,
"isLive": false,
"totalGifts": 0,
"totalIncome": 0,
"commissionRate": 0.7,
"status": "approved",
"verifiedAt": now,
"createdAt": now,
"updatedAt": now,
"userId": uid,
"name": "主播",
"title": "开发环境 · 自动过审主播",
"game": "英雄联盟",
"gameId": 2,
"avatar": "",
"coverImage": "/lol-stream.jpg",
"tags": []string{"dev", "auto-approved"},
"level": 1,
"fans": 100,
"hotValue": 1,
"isLive": false,
"totalGifts": 0,
"totalIncome": 0,
"commissionRate": 0.7,
"status": "approved",
"verifiedAt": now,
"createdAt": now,
"updatedAt": now,
"devAutoProvision": true,
}
_, _ = wzstore.Insert(db, wzstore.CollStreamers, body)
st, err := wzstore.FindApprovedStreamerForUser(db, uid)
_, _ = wzstore.Insert(wzstore.CollStreamers, body)
st, err := wzstore.FindApprovedStreamerForUser(uid)
if err != nil {
return nil
}
@@ -123,18 +120,15 @@ func WzAppStreamerBind(c *gin.Context) {
if sid == "" && len(ref) == 24 && playhubIsHexID(ref) {
sid = ref
}
db := database.DB()
if sid == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的主播标识 ref"})
return
}
_, err := wzstore.Get(db, wzstore.CollStreamers, sid)
if err != nil {
if _, err := wzstore.Get(wzstore.CollStreamers, sid); err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "主播不存在"})
return
}
days := 30
// STREAMER_BINDING_DAYS optional
now := time.Now().UTC()
expires := now.Add(time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
doc := map[string]interface{}{
@@ -146,15 +140,11 @@ func WzAppStreamerBind(c *gin.Context) {
"createdAt": now.Format(time.RFC3339),
"updatedAt": now.Format(time.RFC3339),
}
var cnt int64
db.Model(&model.WzDocument{}).Where("coll = ?", wzstore.CollStreamerBindings).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.userId')) = ?", userID).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.status')) = ?", "active").
Count(&cnt)
cnt, _ := wzstore.CountActiveBindingsForUser(userID)
if cnt > 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已有有效绑定,未变更"})
return
}
_, _ = wzstore.Insert(db, wzstore.CollStreamerBindings, doc)
_, _ = wzstore.Insert(wzstore.CollStreamerBindings, doc)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "绑定成功"})
}

View File

@@ -1,17 +1,18 @@
package handler
import (
"errors"
"net/http"
"strings"
"wz-api/internal/auth"
"wz-api/internal/config"
"wz-api/internal/database"
"wz-api/internal/model"
mongostore "wz-api/internal/store/mongo"
"github.com/gin-gonic/gin"
mongodriver "go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func userDisplayName(u *model.User) string {
@@ -63,10 +64,9 @@ func WanzhiAppLogin(c *gin.Context) {
return
}
db := database.DB()
var ua model.UserAuth
if err := db.Where("auth_type = ? AND auth_id = ?", "phone", phone).First(&ua).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ua, err := mongostore.FindUserAuthByPhone(phone)
if err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "手机号未注册,请先注册"})
return
}
@@ -78,8 +78,8 @@ func WanzhiAppLogin(c *gin.Context) {
return
}
var user model.User
if err := db.Where("id = ?", ua.UserID).First(&user).Error; err != nil {
user, err := mongostore.FindAppUserByID(ua.UserID)
if err != nil || user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "用户不存在"})
return
}
@@ -94,9 +94,9 @@ func WanzhiAppLogin(c *gin.Context) {
"success": true,
"data": gin.H{
"id": user.ID,
"name": userDisplayName(&user),
"phone": userPhoneStr(&user),
"avatar": userAvatarStr(c, &user),
"name": userDisplayName(user),
"phone": userPhoneStr(user),
"avatar": userAvatarStr(c, user),
"token": token,
},
})
@@ -133,9 +133,8 @@ func WanzhiAppRegister(c *gin.Context) {
return
}
db := database.DB()
var n int64
if err := db.Model(&model.UserAuth{}).Where("auth_type = ? AND auth_id = ?", "phone", phone).Count(&n).Error; err != nil {
n, err := mongostore.CountPhoneAuths(phone)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "注册失败"})
return
}
@@ -151,42 +150,7 @@ func WanzhiAppRegister(c *gin.Context) {
}
userID := "user_" + randomSuffix()
nickname := name
phoneCopy := phone
avatar := ""
hasFullBook := false
earnings := 0.0
pendingEarnings := 0.0
referralCount := 0
purchasedSections := "[]"
err = db.Transaction(func(tx *gorm.DB) error {
u := model.User{
ID: userID,
Nickname: &nickname,
Phone: &phoneCopy,
Avatar: &avatar,
HasFullBook: &hasFullBook,
PurchasedSections: &purchasedSections,
Earnings: &earnings,
PendingEarnings: &pendingEarnings,
ReferralCount: &referralCount,
}
if err := tx.Create(&u).Error; err != nil {
return err
}
ua := model.UserAuth{
UserID: userID,
AuthType: "phone",
AuthID: phone,
Credential: string(hash),
}
if err := tx.Create(&ua).Error; err != nil {
return err
}
return nil
})
if err != nil {
if err := mongostore.CreateAppRegister(userID, phone, name, string(hash)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "注册失败"})
return
}
@@ -228,18 +192,13 @@ func WanzhiAppUserMe(c *gin.Context) {
return
}
db := database.DB()
var user model.User
if err := db.Where("id = ?", claims.UserID).First(&user).Error; err != nil {
user, err := mongostore.FindAppUserByID(claims.UserID)
if err != nil || user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
var balance float64
var ub model.UserBalance
if err := db.Where("user_id = ?", user.ID).First(&ub).Error; err == nil {
balance = ub.Balance
}
balance := mongostore.GetAppUserBalance(user.ID)
isVip := false
if user.IsVip != nil {
@@ -250,9 +209,9 @@ func WanzhiAppUserMe(c *gin.Context) {
"success": true,
"data": gin.H{
"id": user.ID,
"name": userDisplayName(&user),
"phone": userPhoneStr(&user),
"avatar": userAvatarStr(c, &user),
"name": userDisplayName(user),
"phone": userPhoneStr(user),
"avatar": userAvatarStr(c, user),
"level": 1,
"balance": balance,
"isVip": isVip,
@@ -274,8 +233,8 @@ func PublicBuildInfo(c *gin.Context) {
msg = "当前部署版本:" + bt
}
c.JSON(http.StatusOK, gin.H{
"buildTime": bt,
"gitCommit": gc,
"message": msg,
"buildTime": bt,
"gitCommit": gc,
"message": msg,
})
}

View File

@@ -2,6 +2,7 @@ package router
import (
"context"
"strings"
"time"
"wz-api/internal/config"
@@ -15,7 +16,7 @@ import (
"github.com/gin-gonic/gin"
)
// Setup 创建并配置 Gin 引擎。Soul 历史路由仍大量存在玩值old契约按《开发文档/玩值/迁移-API对照表》逐步迁入新 C 端统一走 /api/app/*
// Setup Mongo-only仅注册玩值 C 端 /api/app、大屏 /api/screen、公共 /api/public、管理端登录与 /api/admin/wz/*(及 wanzhi_admin mock 契约)
func Setup(cfg *config.Config) *gin.Engine {
gin.SetMode(cfg.Mode)
r := gin.New()
@@ -23,14 +24,50 @@ func Setup(cfg *config.Config) *gin.Engine {
r.Use(gin.Logger())
_ = r.SetTrustedProxies(cfg.TrustedProxies)
// CORS 须尽早注册,使 OPTIONS 预检在 Secure/gzip 之前得到 Access-Control-* 响应(避免上游或安全头导致无 CORS 头)
r.Use(cors.New(cors.Config{
corsCfg := cors.Config{
AllowOrigins: cfg.CORSOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "Accept", "Accept-Language"},
AllowCredentials: true,
MaxAge: 86400,
}))
}
if strings.ToLower(cfg.Mode) == "debug" {
allowedOrigin := make(map[string]struct{}, len(cfg.CORSOrigins)+8)
for _, o := range cfg.CORSOrigins {
if o = strings.TrimSpace(o); o != "" {
allowedOrigin[o] = struct{}{}
}
}
corsCfg.AllowOriginFunc = func(origin string) bool {
if origin == "" {
return false
}
if _, ok := allowedOrigin[origin]; ok {
return true
}
if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") {
return true
}
if strings.HasPrefix(origin, "http://[::1]:") {
return true
}
if strings.HasPrefix(origin, "http://192.168.") || strings.HasPrefix(origin, "http://10.") {
return true
}
if strings.HasPrefix(origin, "http://172.16.") || strings.HasPrefix(origin, "http://172.17.") ||
strings.HasPrefix(origin, "http://172.18.") || strings.HasPrefix(origin, "http://172.19.") ||
strings.HasPrefix(origin, "http://172.20.") || strings.HasPrefix(origin, "http://172.21.") ||
strings.HasPrefix(origin, "http://172.22.") || strings.HasPrefix(origin, "http://172.23.") ||
strings.HasPrefix(origin, "http://172.24.") || strings.HasPrefix(origin, "http://172.25.") ||
strings.HasPrefix(origin, "http://172.26.") || strings.HasPrefix(origin, "http://172.27.") ||
strings.HasPrefix(origin, "http://172.28.") || strings.HasPrefix(origin, "http://172.29.") ||
strings.HasPrefix(origin, "http://172.30.") || strings.HasPrefix(origin, "http://172.31.") {
return true
}
return false
}
}
r.Use(cors.New(corsCfg))
r.Use(middleware.Secure())
r.Use(gzip.Gzip(gzip.DefaultCompression))
rateLimiter := middleware.NewRateLimiter(100, 200)
@@ -45,15 +82,8 @@ func Setup(cfg *config.Config) *gin.Engine {
r.Static("/uploads", uploadDir)
r.Static("/static", "./static")
// H5 落地页:朋友圈分享链接,文章预览 + 打开小程序按钮
r.GET("/read/:id", handler.H5ReadPage)
// WebSocket小程序在线心跳占位后续可扩展管理端订阅、消息推送
r.GET("/ws/miniprogram", handler.WsMiniprogram)
api := r.Group("/api")
{
// ----- 玩值 C 端统一前缀(/api/appC 端真源为 old/ Next+React历史 uni 见 new/wz-app/README
app := api.Group("/app")
{
app.GET("/health", func(c *gin.Context) {
@@ -73,432 +103,34 @@ func Setup(cfg *config.Config) *gin.Engine {
public.GET("/build-info", handler.PublicBuildInfo)
}
// ----- 管理端 -----
api.GET("/admin", handler.AdminCheck)
api.POST("/admin", handler.AdminLogin)
api.POST("/admin/logout", handler.AdminLogout)
// 玩值管理端契约(旧 Next /api/admin/auth/login
api.POST("/admin/auth/login", handler.AdminLogin)
api.POST("/admin/auth/logout", handler.AdminLogout)
admin := api.Group("/admin")
admin.Use(middleware.AdminAuth())
{
// 玩值 old 管理端契约(与 Soul 冲突的 GET /users、GET /orders 已移除,由本组接管)
handler.RegisterWanzhiAdminRoutes(admin)
// 玩值真实数据:/api/admin/wz/*wz_documents / MySQL
handler.RegisterPlayhubWzAdminRoutes(admin)
admin.GET("/chapters", handler.AdminChaptersList)
admin.POST("/chapters", handler.AdminChaptersAction)
admin.PUT("/chapters", handler.AdminChaptersAction)
admin.DELETE("/chapters", handler.AdminChaptersAction)
admin.GET("/content", handler.AdminContent)
admin.POST("/content", handler.AdminContent)
admin.PUT("/content", handler.AdminContent)
admin.DELETE("/content", handler.AdminContent)
admin.GET("/dashboard/stats", handler.AdminDashboardStats)
admin.GET("/dashboard/recent-orders", handler.AdminDashboardRecentOrders)
admin.GET("/dashboard/new-users", handler.AdminDashboardNewUsers)
admin.GET("/dashboard/overview", handler.AdminDashboardOverview)
admin.GET("/dashboard/merchant-balance", handler.AdminDashboardMerchantBalance)
admin.GET("/distribution/overview", handler.AdminDistributionOverview)
admin.GET("/distribution/ranking", handler.AdminDistributionRanking)
admin.GET("/payment", handler.AdminPayment)
admin.POST("/payment", handler.AdminPayment)
admin.PUT("/payment", handler.AdminPayment)
admin.DELETE("/payment", handler.AdminPayment)
admin.GET("/referral", handler.AdminReferral)
admin.POST("/referral", handler.AdminReferral)
admin.PUT("/referral", handler.AdminReferral)
admin.DELETE("/referral", handler.AdminReferral)
admin.GET("/withdrawals", handler.AdminWithdrawalsList)
admin.PUT("/withdrawals", handler.AdminWithdrawalsAction)
admin.POST("/withdrawals/cancel", handler.AdminWithdrawalsCancel)
admin.POST("/withdrawals/sync", handler.AdminWithdrawalsSync)
admin.GET("/withdrawals/auto-approve", handler.AdminWithdrawalsAutoApproveGet)
admin.PUT("/withdrawals/auto-approve", handler.AdminWithdrawalsAutoApprovePut)
admin.GET("/withdraw-test", handler.AdminWithdrawTest)
admin.POST("/withdraw-test", handler.AdminWithdrawTest)
admin.GET("/settings", handler.AdminSettingsGet)
admin.POST("/settings", handler.AdminSettingsPost)
admin.GET("/linked-miniprograms", handler.AdminLinkedMpList)
admin.POST("/linked-miniprograms", handler.AdminLinkedMpCreate)
admin.PUT("/linked-miniprograms", handler.AdminLinkedMpUpdate)
admin.DELETE("/linked-miniprograms/:id", handler.AdminLinkedMpDelete)
admin.GET("/referral-settings", handler.AdminReferralSettingsGet)
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
// 存客宝开放 API 辅助接口:设备列表(供链接人与事选择设备)
admin.GET("/ckb/devices", handler.AdminCKBDevices)
// 存客宝开放 API 辅助接口:获客计划列表(供链接人与事一键选择计划覆盖参数)
admin.GET("/ckb/plans", handler.AdminCKBPlans)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
admin.POST("/shensheshou/enrich", handler.AdminShensheShouEnrich)
admin.POST("/shensheshou/ingest", handler.AdminShensheShouIngest)
admin.POST("/shensheshou/batch-enrich", handler.AdminShensheShouBatchEnrich)
admin.PUT("/orders/refund", handler.AdminOrderRefund)
admin.GET("/users/:id/balance", handler.AdminUserBalanceGet)
admin.POST("/users/:id/balance/adjust", handler.AdminUserBalanceAdjust)
admin.GET("/balance/summary", handler.AdminBalanceSummary)
admin.GET("/users/online-stats", handler.AdminUsersOnlineStats)
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
admin.DELETE("/users", handler.AdminUsersAction)
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
admin.GET("/super-individual/stats", handler.AdminSuperIndividualStats)
admin.GET("/user/track", handler.UserTrackGet)
admin.GET("/track/stats", handler.AdminTrackStats)
admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck)
admin.GET("/mbti-avatars", handler.AdminMbtiAvatarsGet)
admin.POST("/mbti-avatars", handler.AdminMbtiAvatarsPost)
admin.POST("/mbti-avatars/batch-assign", handler.AdminMbtiAvatarsBatchAssign)
admin.GET("/open-platform/keys", handler.AdminOpenPlatformKeysList)
admin.POST("/open-platform/keys", handler.AdminOpenPlatformKeysCreate)
admin.POST("/open-platform/keys/:id/revoke", handler.AdminOpenPlatformKeyRevoke)
admin.GET("/open-platform/logs", handler.AdminOpenPlatformLogsList)
admin.GET("/dashboard/leads", handler.AdminDashboardLeads)
admin.GET("/chapters/:id/click-stats", handler.AdminChapterClickStats)
admin.GET("/dashboard/content-ranking", handler.AdminDashboardContentRanking)
admin.GET("/rfm-weights", handler.AdminRFMWeightsGet)
admin.PUT("/rfm-weights", handler.AdminRFMWeightsPut)
admin.GET("/leaderboard", handler.AdminLeaderboard)
admin.POST("/ckb/push-content", handler.AdminCKBPushContent)
}
// ----- 开放平台API KeyAuthorization Bearer op_sk_... 或 X-API-Key-----
open := api.Group("/open")
open.Use(handler.OpenPlatformAuthMiddleware())
{
open.POST("/user/profile", handler.OpenPlatformPatchUserProfile)
}
// ----- 鉴权 -----
api.POST("/auth/login", handler.AuthLogin)
api.POST("/auth/reset-password", handler.AuthResetPassword)
// ----- 书籍/章节(只读,写操作由 /api/db/book 管理端路由承担) -----
api.GET("/book/books", handler.BooksList)
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part保留以兼容 next-project/管理端
api.GET("/book/all-chapters", handler.BookAllChapters)
api.GET("/book/parts", handler.BookParts)
api.GET("/book/chapters-by-part", handler.BookChaptersByPart)
api.GET("/book/chapter/:id", handler.BookChapterByID)
api.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
api.GET("/book/chapters", handler.BookChapters)
// POST/PUT/DELETE /api/book/chapters 已移除:写操作须由管理端 /api/db/bookAdminAuth完成
api.GET("/book/hot", handler.BookHot)
api.GET("/book/recommended", handler.BookRecommended)
api.GET("/book/latest-chapters", handler.BookLatestChapters)
api.GET("/book/search", handler.BookSearch)
api.GET("/book/stats", handler.BookStats)
api.GET("/book/sync", handler.BookSync)
api.POST("/book/sync", handler.BookSync)
// ----- CKB -----
api.POST("/ckb/join", handler.CKBJoin)
api.POST("/ckb/match", handler.CKBMatch)
api.GET("/ckb/sync", handler.CKBSync)
api.POST("/ckb/sync", handler.CKBSync)
api.GET("/ckb/content", handler.CKBContentList)
// ----- 配置 -----
api.GET("/config", handler.GetConfig)
// Deprecated: 小程序已迁移至 /miniprogram/config/core + audit-mode + read-extras保留以兼容 next-project
api.GET("/db/config", handler.GetPublicDBConfig)
// ----- 内容 -----
api.GET("/content", handler.ContentGet)
// ----- 定时任务(须携带 X-Cron-Secret 请求头,与 .env CRON_SECRET 一致) -----
cron := api.Group("/cron")
cron.Use(middleware.CronAuth())
{
cron.GET("/sync-orders", handler.CronSyncOrders)
cron.POST("/sync-orders", handler.CronSyncOrders)
cron.GET("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.POST("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.GET("/retry-ckb-leads", handler.CronRetryCkbLeads)
cron.POST("/retry-ckb-leads", handler.CronRetryCkbLeads)
cron.GET("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.POST("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.GET("/unbind-expired", handler.CronUnbindExpired)
cron.POST("/unbind-expired", handler.CronUnbindExpired)
}
// ----- 数据库(管理端) -----
db := api.Group("/db")
db.Use(middleware.AdminAuth())
{
db.GET("/books", handler.DBBooksAction)
db.POST("/books", handler.DBBooksAction)
db.PUT("/books", handler.DBBooksAction)
db.DELETE("/books", handler.DBBooksAction)
db.GET("/book", handler.DBBookAction)
db.POST("/book", handler.DBBookAction)
db.PUT("/book", handler.DBBookAction)
db.DELETE("/book", handler.DBBookDelete)
db.GET("/chapters", handler.DBChapters)
db.POST("/chapters", handler.DBChapters)
db.GET("/config/full", handler.DBConfigGet) // 管理端拉全量配置GET /api/db/config 已用于公开接口 GetPublicDBConfig
db.POST("/config", handler.DBConfigPost)
db.DELETE("/config", handler.DBConfigDelete)
db.GET("/distribution", handler.DBDistribution)
db.GET("/init", handler.DBInitGet)
db.POST("/init", handler.DBInit)
db.GET("/migrate", handler.DBMigrateGet)
db.POST("/migrate", handler.DBMigratePost)
db.GET("/users", handler.DBUsersList)
db.POST("/users", handler.DBUsersAction)
db.PUT("/users", handler.DBUsersAction)
db.DELETE("/users", handler.DBUsersDelete)
db.GET("/users/referrals", handler.DBUsersReferrals)
db.GET("/users/rfm", handler.DBUsersRFM)
db.GET("/users/rfm-single", handler.DBUserRFMSingle)
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
db.GET("/users/journey-users", handler.DBUsersJourneyUsers)
db.GET("/users/tracks", handler.DBUserTracksList)
db.GET("/vip-roles", handler.DBVipRolesList)
db.POST("/vip-roles", handler.DBVipRolesAction)
db.PUT("/vip-roles", handler.DBVipRolesAction)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/vip-members", handler.DBVipMembersList)
db.PUT("/vip-members/webhook", handler.DBVipMemberWebhookSet)
db.PUT("/persons/home-entry-config", handler.DBPersonHomeEntryConfigSave)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
db.GET("/mentors", handler.DBMentorsList)
db.POST("/mentors", handler.DBMentorsAction)
db.PUT("/mentors", handler.DBMentorsAction)
db.DELETE("/mentors", handler.DBMentorsAction)
db.GET("/mentor-consultations", handler.DBMentorConsultationsList)
db.GET("/persons", handler.DBPersonList)
db.GET("/persons/check-unique", handler.DBPersonCheckUnique)
db.GET("/person", handler.DBPersonDetail)
db.GET("/persons/pinned-token", handler.DBPersonPinnedToken)
db.PUT("/persons/pin", handler.DBPersonPin)
db.POST("/persons", handler.DBPersonSave)
db.DELETE("/persons", handler.DBPersonDelete)
db.GET("/link-tags", handler.DBLinkTagList)
db.POST("/link-tags", handler.DBLinkTagSave)
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/persons/pinned", handler.DBPersonPinnedList)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.POST("/ckb-leads/retry", handler.DBCKBLeadRetry)
db.POST("/ckb-leads/delete", handler.DBCKBLeadDelete)
db.POST("/ckb-leads/delete-batch", handler.DBCKBLeadDeleteBatch)
db.GET("/ckb-person-leads", handler.DBCKBPersonLeads)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/user-rules", handler.DBUserRulesList)
db.POST("/user-rules", handler.DBUserRulesAction)
db.PUT("/user-rules", handler.DBUserRulesAction)
db.DELETE("/user-rules", handler.DBUserRulesAction)
}
// ----- 分销 -----
api.GET("/distribution", handler.DistributionGet)
api.POST("/distribution", handler.DistributionGet)
api.PUT("/distribution", handler.DistributionGet)
api.GET("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
api.POST("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
api.DELETE("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
api.GET("/distribution/messages", handler.DistributionMessages)
api.POST("/distribution/messages", handler.DistributionMessages)
// ----- 文档生成 -----
api.POST("/documentation/generate", handler.DocGenerate)
// ----- 找伙伴 -----
api.GET("/match/config", handler.MatchConfigGet)
api.POST("/match/config", handler.MatchConfigPost)
api.POST("/match/users", matchLimiter.Middleware(), handler.MatchUsers)
// ----- 菜单 -----
api.GET("/menu", handler.MenuGet)
// /api/orders 已移入 admin 组(需鉴权),见下方
// ----- 支付 -----
api.POST("/payment/alipay/notify", handler.PaymentAlipayNotify)
api.POST("/payment/callback", handler.PaymentCallback)
api.POST("/payment/create-order", handler.PaymentCreateOrder)
api.GET("/payment/methods", handler.PaymentMethods)
api.GET("/payment/query", handler.PaymentQuery)
api.GET("/payment/status/:orderSn", handler.PaymentStatusOrderSn)
api.POST("/payment/verify", handler.PaymentVerify)
api.POST("/payment/wechat/notify", handler.PaymentWechatNotify)
api.GET("/payment/wechat/transfer/notify", handler.PaymentWechatTransferNotify)
api.POST("/payment/wechat/transfer/notify", handler.PaymentWechatTransferNotify)
// ----- 推荐 -----
api.POST("/referral/bind", handler.ReferralBind)
api.GET("/referral/data", handler.ReferralData)
api.POST("/referral/visit", handler.ReferralVisit)
// ----- 搜索 -----
api.GET("/search", handler.SearchGet)
// ----- 同步 -----
api.GET("/sync", handler.SyncGet)
api.POST("/sync", handler.SyncPost)
api.PUT("/sync", handler.SyncPut)
// ----- 上传 -----
api.POST("/upload", handler.UploadPost)
api.DELETE("/upload", handler.UploadDelete)
// ----- 用户 -----
api.GET("/user/addresses", handler.UserAddressesGet)
api.POST("/user/addresses", handler.UserAddressesPost)
api.GET("/user/addresses/:id", handler.UserAddressesByID)
api.PUT("/user/addresses/:id", handler.UserAddressesByID)
api.DELETE("/user/addresses/:id", handler.UserAddressesByID)
api.GET("/user/check-purchased", handler.UserCheckPurchased)
api.GET("/user/profile", handler.UserProfileGet)
api.POST("/user/profile", handler.UserProfilePost)
api.GET("/user/purchase-status", handler.UserPurchaseStatus)
api.GET("/user/reading-progress", handler.UserReadingProgressGet)
api.POST("/user/reading-progress", handler.UserReadingProgressPost)
api.GET("/user/track", handler.UserTrackGet)
api.POST("/user/track", handler.UserTrackPost)
api.POST("/user/update", handler.UserUpdate)
// ----- 微信登录 -----
api.POST("/wechat/login", handler.WechatLogin)
api.POST("/wechat/phone-login", handler.WechatPhoneLogin)
// ----- 小程序组(所有小程序端接口统一在 /api/miniprogram 下) -----
miniprogram := api.Group("/miniprogram")
{
// config 拆分接口(优先匹配,路径更具体)
miniprogram.GET("/config/audit-mode", handler.GetAuditMode)
miniprogram.GET("/config/core", handler.GetCoreConfig)
miniprogram.GET("/config/read-extras", handler.GetReadExtras)
miniprogram.GET("/config/mbti-avatars", handler.MiniprogramMbtiAvatarsGet)
// Deprecated: 保留以兼容线上,计划迁移至上述拆分接口
miniprogram.GET("/config", handler.GetPublicDBConfig)
miniprogram.POST("/login", handler.MiniprogramLogin)
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
miniprogram.POST("/dev/login-by-phone", handler.MiniprogramDevLoginByPhone) // 开发专用:按手机号登录(密码可空)
miniprogram.POST("/phone", handler.MiniprogramPhone)
miniprogram.GET("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调URL 需在商户平台配置
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage)
// 书籍列表(多书架构)
miniprogram.GET("/book/books", handler.BooksList)
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
miniprogram.GET("/book/parts", handler.BookParts)
miniprogram.GET("/book/chapters-by-part", handler.BookChaptersByPart)
miniprogram.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
miniprogram.GET("/book/chapter/by-id/:id", handler.BookChapterByID)
miniprogram.GET("/book/hot", handler.BookHot)
miniprogram.GET("/book/recommended", handler.BookRecommended)
miniprogram.GET("/book/ranking", handler.BookRanking)
miniprogram.GET("/book/latest-chapters", handler.BookLatestChapters)
miniprogram.GET("/book/search", handler.BookSearch)
miniprogram.GET("/book/stats", handler.BookStats)
miniprogram.POST("/referral/visit", handler.ReferralVisit)
miniprogram.POST("/referral/bind", handler.ReferralBind)
miniprogram.GET("/referral/data", handler.ReferralData)
miniprogram.GET("/earnings", handler.MyEarnings)
miniprogram.GET("/earnings/leaderboard", handler.EarningsLeaderboard)
miniprogram.GET("/match/config", handler.MatchConfigGet)
miniprogram.GET("/match/history", handler.MiniprogramMatchHistory)
miniprogram.POST("/match/users", matchLimiter.Middleware(), handler.MatchUsers)
miniprogram.POST("/ckb/join", handler.CKBJoin)
miniprogram.POST("/ckb/match", handler.CKBMatch)
miniprogram.GET("/ckb/pinned-person", handler.CKBPinnedPerson)
miniprogram.POST("/ckb/lead", handler.CKBLead)
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
miniprogram.POST("/upload", handler.UploadPost)
miniprogram.DELETE("/upload", handler.UploadDelete)
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
miniprogram.POST("/user/addresses", handler.UserAddressesPost)
miniprogram.GET("/user/addresses/:id", handler.UserAddressesByID)
miniprogram.PUT("/user/addresses/:id", handler.UserAddressesByID)
miniprogram.DELETE("/user/addresses/:id", handler.UserAddressesByID)
miniprogram.GET("/user/check-purchased", handler.UserCheckPurchased)
miniprogram.GET("/user/dashboard-stats", handler.UserDashboardStats)
miniprogram.GET("/user/profile", handler.UserProfileGet)
miniprogram.POST("/user/profile", handler.UserProfilePost)
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
miniprogram.POST("/user/update", handler.UserUpdate)
miniprogram.POST("/withdraw", handler.WithdrawPost)
miniprogram.GET("/withdraw/records", handler.WithdrawRecords)
miniprogram.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
miniprogram.POST("/withdraw/confirm-received", handler.WithdrawConfirmReceived)
miniprogram.GET("/withdraw/confirm-info", handler.WithdrawConfirmInfo)
// VIP 接口(小程序专用,按使用方区分路径)
miniprogram.GET("/vip/status", handler.VipStatus)
miniprogram.GET("/vip/profile", handler.VipProfileGet)
miniprogram.POST("/vip/profile", handler.VipProfilePost)
miniprogram.GET("/vip/members", handler.VipMembers)
// 用户列表/单个(首页超级个体、会员详情回退)
miniprogram.GET("/users", handler.MiniprogramUsers)
miniprogram.GET("/orders", handler.MiniprogramOrders)
// 导师stitch_soul
miniprogram.GET("/mentors", handler.MiniprogramMentorsList)
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
miniprogram.GET("/persons/pinned", handler.DBPersonPinnedList)
// 埋点
miniprogram.POST("/track", handler.MiniprogramTrackPost)
// 规则引擎(用户旅程触达)
miniprogram.GET("/user-rules", handler.MiniprogramUserRulesGet)
miniprogram.POST("/user-rules/complete", handler.MiniprogramUserRuleComplete)
// 余额
miniprogram.GET("/balance", handler.BalanceGet)
miniprogram.GET("/balance/transactions", handler.BalanceTransactionsGet)
miniprogram.POST("/balance/recharge", handler.BalanceRechargePost)
miniprogram.POST("/balance/recharge/confirm", handler.BalanceRechargeConfirmPost)
miniprogram.POST("/balance/refund", handler.BalanceRefundPost)
miniprogram.POST("/balance/consume", handler.BalanceConsumePost)
miniprogram.GET("/gift/link", handler.GiftLinkGet)
// 代付(改造后:发起人支付,好友领取)
miniprogram.POST("/gift-pay/create", handler.GiftPayCreate)
miniprogram.POST("/gift-pay/initiator-pay", handler.GiftPayInitiatorPay)
miniprogram.GET("/gift-pay/detail", handler.GiftPayDetail)
miniprogram.POST("/gift-pay/redeem", handler.GiftPayRedeem)
miniprogram.POST("/gift-pay/cancel", handler.GiftPayCancel)
miniprogram.GET("/gift-pay/my-requests", handler.GiftPayMyRequests)
miniprogram.GET("/my/super-stats", handler.MiniprogramMySuperStats)
miniprogram.GET("/my/super-leads", handler.MiniprogramMySuperLeads)
miniprogram.GET("/my/super-lead-tracks", handler.MiniprogramMySuperLeadTracks)
}
// ----- 提现 -----
api.POST("/withdraw", handler.WithdrawPost)
api.GET("/withdraw/records", handler.WithdrawRecords)
api.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
// 提现测试(固定用户 1 元,无需 admin 鉴权,仅用于脚本/本地调试)
api.GET("/withdraw-test", handler.AdminWithdrawTest)
api.POST("/withdraw-test", handler.AdminWithdrawTest)
// ----- 提现 V3独立实现依文档 提现功能完整技术文档.md -----
api.POST("/v3/withdraw/initiate", handler.WithdrawV3Initiate)
api.POST("/v3/withdraw/notify", handler.WithdrawV3Notify)
api.POST("/v3/withdraw/query", handler.WithdrawV3Query)
}
// 根路径不返回任何页面(仅 204
r.GET("/", func(c *gin.Context) {
c.Status(204)
})
// 健康检查:返回状态、版本号、数据库与 Redis 连接状态
r.GET("/health", func(c *gin.Context) {
dbStatus := "ok"
if sqlDB, err := database.DB().DB(); err != nil {
dbStatus = "error"
} else if err := sqlDB.Ping(); err != nil {
dbStatus = "disconnected"
mongoStatus := "ok"
if mc := database.MongoClient(); mc != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := mc.Ping(ctx, nil); err != nil {
mongoStatus = "error"
}
} else {
mongoStatus = "disabled"
}
redisStatus := "disabled"
@@ -511,10 +143,13 @@ func Setup(cfg *config.Config) *gin.Engine {
}
c.JSON(200, gin.H{
"status": "ok",
"version": cfg.Version,
"database": dbStatus,
"redis": redisStatus,
"status": "ok",
"version": cfg.Version,
"database": mongoStatus,
"mongo": mongoStatus,
"redis": redisStatus,
"storage": "mongodb",
"mysqlGone": true,
})
})

View File

@@ -0,0 +1,288 @@
// Package mongostore 封装 C 端账号、管理端登录等 Mongo 集合访问。
// C 端主档app_users认证user_auths余额user_balances后台admin_users与玩值 users 业务集合分离)。
package mongostore
import (
"context"
"time"
"wz-api/internal/database"
"wz-api/internal/model"
"go.mongodb.org/mongo-driver/bson"
mongodriver "go.mongodb.org/mongo-driver/mongo"
)
const (
CollAdminUsers = "admin_users"
CollAppUsers = "app_users"
CollUserAuths = "user_auths"
CollUserBalances = "user_balances"
)
func coll(name string) *mongodriver.Collection {
return database.MongoDatabase().Collection(name)
}
func opCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 25*time.Second)
}
// FindAdminByUsername 按用户名查管理员;无文档时 ErrNoDocuments。
func FindAdminByUsername(username string) (*model.AdminUser, error) {
ctx, cancel := opCtx()
defer cancel()
var raw bson.M
err := coll(CollAdminUsers).FindOne(ctx, bson.M{"username": username}).Decode(&raw)
if err != nil {
return nil, err
}
return decodeAdmin(raw)
}
func decodeAdmin(raw bson.M) (*model.AdminUser, error) {
id := uintFromValue(raw["id"])
u := &model.AdminUser{
ID: id,
Username: stringFromValue(raw["username"]),
PasswordHash: stringFromValue(raw["password_hash"]),
Role: stringFromValue(raw["role"]),
Name: stringFromValue(raw["name"]),
Status: stringFromValue(raw["status"]),
}
if u.Status == "" {
u.Status = "active"
}
if ts, ok := raw["created_at"].(time.Time); ok {
u.CreatedAt = ts
} else if s := stringFromValue(raw["created_at"]); s != "" {
if t, e := time.Parse(time.RFC3339, s); e == nil {
u.CreatedAt = t
}
}
if ts, ok := raw["updated_at"].(time.Time); ok {
u.UpdatedAt = ts
} else if s := stringFromValue(raw["updated_at"]); s != "" {
if t, e := time.Parse(time.RFC3339, s); e == nil {
u.UpdatedAt = t
}
}
return u, nil
}
// CountAdminUsers admin_users 文档数。
func CountAdminUsers() (int64, error) {
ctx, cancel := opCtx()
defer cancel()
return coll(CollAdminUsers).CountDocuments(ctx, bson.M{})
}
// InsertAdmin 创建管理员Mongo 侧自增 id取当前最大 id+1
func InsertAdmin(a *model.AdminUser) error {
ctx, cancel := opCtx()
defer cancel()
if a.ID == 0 {
n, err := CountAdminUsers()
if err != nil {
return err
}
a.ID = uint(n) + 1
}
now := time.Now().UTC()
if a.CreatedAt.IsZero() {
a.CreatedAt = now
}
a.UpdatedAt = now
doc := bson.M{
"id": a.ID,
"username": a.Username,
"password_hash": a.PasswordHash,
"role": a.Role,
"name": a.Name,
"status": a.Status,
"created_at": a.CreatedAt,
"updated_at": a.UpdatedAt,
}
_, err := coll(CollAdminUsers).InsertOne(ctx, doc)
return err
}
// FindUserAuthByPhone auth_type=phone 且 auth_id=手机号。
func FindUserAuthByPhone(phone string) (*model.UserAuth, error) {
ctx, cancel := opCtx()
defer cancel()
var raw bson.M
err := coll(CollUserAuths).FindOne(ctx, bson.M{"auth_type": "phone", "auth_id": phone}).Decode(&raw)
if err != nil {
return nil, err
}
return decodeUserAuth(raw)
}
func decodeUserAuth(raw bson.M) (*model.UserAuth, error) {
ua := &model.UserAuth{
UserID: stringFromValue(raw["user_id"]),
AuthType: stringFromValue(raw["auth_type"]),
AuthID: stringFromValue(raw["auth_id"]),
Credential: stringFromValue(raw["credential"]),
}
return ua, nil
}
// CountPhoneAuths 手机号是否已注册。
func CountPhoneAuths(phone string) (int64, error) {
ctx, cancel := opCtx()
defer cancel()
return coll(CollUserAuths).CountDocuments(ctx, bson.M{"auth_type": "phone", "auth_id": phone})
}
// CreateAppRegister 写入 app_users + user_auths含 bcryptuserID 须已生成。
func CreateAppRegister(userID, phone, nickname, hash string) error {
ctx, cancel := opCtx()
defer cancel()
now := time.Now().UTC()
nick := nickname
phoneCopy := phone
avatar := ""
hasFullBook := false
purchased := "[]"
earnings := 0.0
pending := 0.0
ref := 0
uDoc := bson.M{
"id": userID,
"nickname": nick,
"phone": phoneCopy,
"avatar": avatar,
"has_full_book": hasFullBook,
"purchased_sections": purchased,
"earnings": earnings,
"pending_earnings": pending,
"referral_count": ref,
"created_at": now,
"updated_at": now,
}
if _, err := coll(CollAppUsers).InsertOne(ctx, uDoc); err != nil {
return err
}
authDoc := bson.M{
"user_id": userID,
"auth_type": "phone",
"auth_id": phone,
"credential": hash,
"created_at": now,
"updated_at": now,
}
if _, err := coll(CollUserAuths).InsertOne(ctx, authDoc); err != nil {
return err
}
balDoc := bson.M{
"user_id": userID,
"balance": 0.0,
"created_at": now,
"updated_at": now,
}
_, _ = coll(CollUserBalances).InsertOne(ctx, balDoc)
return nil
}
// FindAppUserByID C 端 users 主档(集合 app_users
func FindAppUserByID(id string) (*model.User, error) {
ctx, cancel := opCtx()
defer cancel()
var raw bson.M
err := coll(CollAppUsers).FindOne(ctx, bson.M{"id": id}).Decode(&raw)
if err != nil {
return nil, err
}
return decodeAppUser(raw)
}
func decodeAppUser(raw bson.M) (*model.User, error) {
u := &model.User{
ID: stringFromValue(raw["id"]),
}
if v, ok := raw["nickname"].(string); ok && v != "" {
u.Nickname = &v
} else if s := stringFromValue(raw["nickname"]); s != "" {
u.Nickname = &s
}
if v, ok := raw["phone"].(string); ok && v != "" {
u.Phone = &v
} else if s := stringFromValue(raw["phone"]); s != "" {
u.Phone = &s
}
if v, ok := raw["avatar"].(string); ok {
u.Avatar = &v
}
if b, ok := raw["is_vip"].(bool); ok {
u.IsVip = &b
}
// 时间
if ts, ok := raw["created_at"].(time.Time); ok {
u.CreatedAt = ts
}
if ts, ok := raw["updated_at"].(time.Time); ok {
u.UpdatedAt = ts
}
return u, nil
}
// GetAppUserBalance 读取 user_balances.balance。
func GetAppUserBalance(userID string) float64 {
ctx, cancel := opCtx()
defer cancel()
var raw bson.M
err := coll(CollUserBalances).FindOne(ctx, bson.M{"user_id": userID}).Decode(&raw)
if err != nil {
return 0
}
switch x := raw["balance"].(type) {
case float64:
return x
case int32:
return float64(x)
case int64:
return float64(x)
default:
return 0
}
}
// AppUserDisplayName 供直播间等展示nickname 或手机号。
func AppUserDisplayName(userID string) string {
u, err := FindAppUserByID(userID)
if err != nil || u == nil {
return "用户"
}
if u.Nickname != nil && *u.Nickname != "" {
return *u.Nickname
}
if u.Phone != nil && *u.Phone != "" {
return *u.Phone
}
return "用户"
}
func stringFromValue(v interface{}) string {
if v == nil {
return ""
}
s, _ := v.(string)
return s
}
func uintFromValue(v interface{}) uint {
switch x := v.(type) {
case int32:
return uint(x)
case int64:
return uint(x)
case float64:
return uint(x)
default:
return 0
}
}

View File

@@ -5,12 +5,10 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"time"
"wz-api/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// ErrNotFound 文档不存在
@@ -22,99 +20,34 @@ func newExtID() string {
return hex.EncodeToString(b)
}
// List 按集合列出(按 body 内 updatedAt/createdAt 降序由应用层排序;此处按 id 降序近似
func List(db *gorm.DB, coll string, limit int) ([]model.WzDocument, error) {
if limit <= 0 || limit > 2000 {
limit = 500
}
var rows []model.WzDocument
err := db.Where("coll = ?", coll).Order("id DESC").Limit(limit).Find(&rows).Error
return rows, err
// List 按 Mongo 集合列出_id 降序)
func List(coll string, limit int) ([]model.WzDocument, error) {
return mongoListDocuments(coll, limit)
}
// Get 按集合 + ext_id
func Get(db *gorm.DB, coll, extID string) (*model.WzDocument, error) {
var d model.WzDocument
err := db.Where("coll = ? AND ext_id = ?", coll, extID).First(&d).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return &d, err
func Get(coll, extID string) (*model.WzDocument, error) {
return mongoGet(coll, extID)
}
// FindByJSONField 等值查询path 为 JSON path如 $.userId
func FindByJSONField(db *gorm.DB, coll, path, value string) (*model.WzDocument, error) {
var d model.WzDocument
err := db.Where("coll = ?", coll).
Where("JSON_UNQUOTE(JSON_EXTRACT(body, ?)) = ?", path, value).
First(&d).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return &d, err
// FindByJSONField 等值查询path $.userId 或 userId与 Mongo 顶层层级字段一致)。
func FindByJSONField(coll, path, value string) (*model.WzDocument, error) {
return mongoFindByField(coll, jsonPathToKey(path), value)
}
// FindOneByField $.key = val
func FindOneByField(db *gorm.DB, coll, key, val string) (*model.WzDocument, error) {
return FindByJSONField(db, coll, "$."+key, val)
// FindOneByField key value 等值
func FindOneByField(coll, key, val string) (*model.WzDocument, error) {
return mongoFindByField(coll, key, val)
}
// Insert 新建文档(body 为 map自动生成 ext_id
func Insert(db *gorm.DB, coll string, body map[string]interface{}) (extID string, err error) {
extID = newExtID()
now := time.Now().UTC().Format(time.RFC3339)
if body == nil {
body = map[string]interface{}{}
}
if _, ok := body["createdAt"]; !ok {
body["createdAt"] = now
}
body["updatedAt"] = now
raw, err := json.Marshal(body)
if err != nil {
return "", err
}
d := model.WzDocument{Coll: coll, ExtID: extID, Body: datatypes.JSON(raw)}
err = db.Create(&d).Error
return extID, err
// Insert 新建文档(自动生成 ext_id
func Insert(coll string, body map[string]interface{}) (extID string, err error) {
return mongoInsert(coll, body)
}
// UpsertByExtID 按 ext_id 写入或更新patch 合并进已有 body
func UpsertByExtID(db *gorm.DB, coll, extID string, body map[string]interface{}) error {
if extID == "" {
return errors.New("ext_id required")
}
now := time.Now().UTC().Format(time.RFC3339)
var d model.WzDocument
err := db.Where("coll = ? AND ext_id = ?", coll, extID).First(&d).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if body == nil {
body = map[string]interface{}{}
}
if body["createdAt"] == nil {
body["createdAt"] = now
}
body["updatedAt"] = now
raw, e := json.Marshal(body)
if e != nil {
return e
}
return db.Create(&model.WzDocument{Coll: coll, ExtID: extID, Body: datatypes.JSON(raw)}).Error
}
if err != nil {
return err
}
merged, e := mergeJSON(d.Body, body)
if e != nil {
return e
}
merged["updatedAt"] = now
raw, e := json.Marshal(merged)
if e != nil {
return e
}
d.Body = datatypes.JSON(raw)
return db.Save(&d).Error
// UpsertByExtID 按 ext_id 写入或更新
func UpsertByExtID(coll, extID string, body map[string]interface{}) error {
return mongoUpsertByExtID(coll, extID, body)
}
func mergeJSON(base datatypes.JSON, patch map[string]interface{}) (map[string]interface{}, error) {
@@ -134,34 +67,13 @@ func mergeJSON(base datatypes.JSON, patch map[string]interface{}) (map[string]in
}
// UpdateJSONPartial 合并更新
func UpdateJSONPartial(db *gorm.DB, coll, extID string, patch map[string]interface{}) error {
d, err := Get(db, coll, extID)
if err != nil {
return err
}
merged, err := mergeJSON(d.Body, patch)
if err != nil {
return err
}
merged["updatedAt"] = time.Now().UTC().Format(time.RFC3339)
raw, err := json.Marshal(merged)
if err != nil {
return err
}
d.Body = datatypes.JSON(raw)
return db.Save(d).Error
func UpdateJSONPartial(coll, extID string, patch map[string]interface{}) error {
return mongoUpdateJSONPartial(coll, extID, patch)
}
// Delete 删除文档
func Delete(db *gorm.DB, coll, extID string) error {
res := db.Where("coll = ? AND ext_id = ?", coll, extID).Delete(&model.WzDocument{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
func Delete(coll, extID string) error {
return mongoDelete(coll, extID)
}
// BodyMap 解析 body
@@ -187,31 +99,13 @@ func ToResponse(d *model.WzDocument) map[string]interface{} {
}
// Count 集合文档数
func Count(db *gorm.DB, coll string) (int64, error) {
var n int64
err := db.Model(&model.WzDocument{}).Where("coll = ?", coll).Count(&n).Error
return n, err
func Count(coll string) (int64, error) {
return mongoCount(coll)
}
// FindApprovedStreamerForUser body.userId 且 status=approved
func FindApprovedStreamerForUser(db *gorm.DB, userID string) (*model.WzDocument, error) {
var rows []model.WzDocument
err := db.Where("coll = ?", CollStreamers).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.userId')) = ?", userID).
Find(&rows).Error
if err != nil {
return nil, err
}
for i := range rows {
m, e := BodyMap(&rows[i])
if e != nil {
continue
}
if strFromMap(m, "status") == "approved" {
return &rows[i], nil
}
}
return nil, ErrNotFound
func FindApprovedStreamerForUser(userID string) (*model.WzDocument, error) {
return mongoFindApprovedStreamerForUser(userID)
}
func strFromMap(m map[string]interface{}, k string) string {
@@ -231,18 +125,27 @@ func strFromMap(m map[string]interface{}, k string) string {
return raw
}
// FindLiveRoomByStreamerExtID streamerId 存 body 为 hex 字符串
func FindLiveRoomByStreamerID(db *gorm.DB, streamerExtID string) (*model.WzDocument, error) {
var rows []model.WzDocument
err := db.Where("coll = ?", CollLiveRooms).
Where("JSON_UNQUOTE(JSON_EXTRACT(body,'$.streamerId')) = ?", streamerExtID).
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
// legacy: body.streamerId 可能是嵌套对象 id — 再试 Mongo ObjectId 字符串匹配整 body
return nil, ErrNotFound
}
return &rows[0], nil
// FindLiveRoomByStreamerExtID streamerId 匹配 ext_id
func FindLiveRoomByStreamerID(streamerExtID string) (*model.WzDocument, error) {
return mongoFindLiveRoomByStreamerID(streamerExtID)
}
// CountBindingsForStreamer streamerBindings.streamerId
func CountBindingsForStreamer(streamerExtID string) (int64, error) {
return mongoCountBindingsForStreamer(streamerExtID)
}
// ListBindingsForStreamer 绑定列表
func ListBindingsForStreamer(streamerExtID string) ([]model.WzDocument, error) {
return mongoListBindingsForStreamer(streamerExtID)
}
// CountJourneyDedup 旅程事件去重sessionId + clientEventId
func CountJourneyDedup(sessionID, clientID string) (int64, error) {
return mongoCountJourneyDedup(sessionID, clientID)
}
// CountActiveBindingsForUser userId+status=active
func CountActiveBindingsForUser(userID string) (int64, error) {
return mongoCountActiveBindingsForUser(userID)
}

View File

@@ -0,0 +1,319 @@
package wzstore
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"wz-api/internal/database"
"wz-api/internal/model"
"go.mongodb.org/mongo-driver/bson"
mongodriver "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"gorm.io/datatypes"
)
const mongoTimeout = 25 * time.Second
func mongoCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), mongoTimeout)
}
func mcoll(name string) *mongodriver.Collection {
return database.MongoDatabase().Collection(name)
}
func extIDFilter(extID string) bson.M {
return bson.M{"$or": []bson.M{
{"ext_id": extID},
{"extId": extID},
}}
}
func rawDocToWzDocument(collName string, raw bson.M) (model.WzDocument, error) {
extID := pickExtID(raw)
if extID == "" {
return model.WzDocument{}, errors.New("wzstore mongo: missing ext_id")
}
bodyMap := make(map[string]interface{})
for k, v := range raw {
if k == "_id" || k == "ext_id" || k == "extId" {
continue
}
bodyMap[k] = v
}
rawJSON, err := json.Marshal(bodyMap)
if err != nil {
return model.WzDocument{}, err
}
return model.WzDocument{Coll: collName, ExtID: extID, Body: datatypes.JSON(rawJSON)}, nil
}
func pickExtID(raw bson.M) string {
if v := raw["ext_id"]; v != nil {
if s, ok := v.(string); ok {
return s
}
}
if v := raw["extId"]; v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// mongoListDocuments 按集合读取文档。
func mongoListDocuments(collName string, limit int) ([]model.WzDocument, error) {
ctx, cancel := mongoCtx()
defer cancel()
if limit <= 0 || limit > 2000 {
limit = 500
}
cur, err := mcoll(collName).Find(ctx, bson.M{}, options.Find().
SetSort(bson.D{{Key: "_id", Value: -1}}).
SetLimit(int64(limit)))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var out []model.WzDocument
for cur.Next(ctx) {
var raw bson.M
if err := cur.Decode(&raw); err != nil {
continue
}
d, err := rawDocToWzDocument(collName, raw)
if err != nil {
continue
}
out = append(out, d)
}
return out, cur.Err()
}
func mongoGet(collName, extID string) (*model.WzDocument, error) {
ctx, cancel := mongoCtx()
defer cancel()
var raw bson.M
err := mcoll(collName).FindOne(ctx, extIDFilter(extID)).Decode(&raw)
if err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, ErrNotFound
}
return nil, err
}
d, err := rawDocToWzDocument(collName, raw)
if err != nil {
return nil, err
}
return &d, nil
}
func mongoFindByField(collName, key, val string) (*model.WzDocument, error) {
ctx, cancel := mongoCtx()
defer cancel()
var raw bson.M
err := mcoll(collName).FindOne(ctx, bson.M{key: val}).Decode(&raw)
if err != nil {
if errors.Is(err, mongodriver.ErrNoDocuments) {
return nil, ErrNotFound
}
return nil, err
}
d, err := rawDocToWzDocument(collName, raw)
if err != nil {
return nil, err
}
return &d, nil
}
func jsonPathToKey(path string) string {
if strings.HasPrefix(path, "$.") {
return path[2:]
}
return path
}
func mongoInsert(collName string, body map[string]interface{}) (string, error) {
ctx, cancel := mongoCtx()
defer cancel()
extID := newExtID()
now := time.Now().UTC().Format(time.RFC3339)
if body == nil {
body = map[string]interface{}{}
}
doc := bson.M{"ext_id": extID}
for k, v := range body {
if k == "ext_id" || k == "extId" || k == "_id" {
continue
}
doc[k] = v
}
if _, ok := doc["createdAt"]; !ok {
doc["createdAt"] = now
}
doc["updatedAt"] = now
if _, err := mcoll(collName).InsertOne(ctx, doc); err != nil {
return "", err
}
return extID, nil
}
func mongoUpsertByExtID(collName, extID string, body map[string]interface{}) error {
if extID == "" {
return errors.New("ext_id required")
}
_, err := mongoGet(collName, extID)
if errors.Is(err, ErrNotFound) {
now := time.Now().UTC().Format(time.RFC3339)
if body == nil {
body = map[string]interface{}{}
}
doc := bson.M{"ext_id": extID}
for k, v := range body {
if k == "ext_id" || k == "extId" || k == "_id" {
continue
}
doc[k] = v
}
if doc["createdAt"] == nil {
doc["createdAt"] = now
}
doc["updatedAt"] = now
ctx, cancel := mongoCtx()
defer cancel()
_, err := mcoll(collName).InsertOne(ctx, doc)
return err
}
if err != nil {
return err
}
return mongoUpdateJSONPartial(collName, extID, body)
}
func mongoUpdateJSONPartial(collName, extID string, patch map[string]interface{}) error {
d, err := mongoGet(collName, extID)
if err != nil {
return err
}
merged, err := mergeJSON(d.Body, patch)
if err != nil {
return err
}
merged["updatedAt"] = time.Now().UTC().Format(time.RFC3339)
ctx, cancel := mongoCtx()
defer cancel()
var raw bson.M
if err := mcoll(collName).FindOne(ctx, extIDFilter(extID)).Decode(&raw); err != nil {
return err
}
id := raw["_id"]
for k, v := range merged {
raw[k] = v
}
raw["_id"] = id
raw["ext_id"] = extID
delete(raw, "extId")
_, err = mcoll(collName).ReplaceOne(ctx, bson.M{"_id": id}, raw)
return err
}
func mongoDelete(collName, extID string) error {
ctx, cancel := mongoCtx()
defer cancel()
res, err := mcoll(collName).DeleteOne(ctx, extIDFilter(extID))
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrNotFound
}
return nil
}
func mongoCount(collName string) (int64, error) {
ctx, cancel := mongoCtx()
defer cancel()
return mcoll(collName).CountDocuments(ctx, bson.M{})
}
func mongoFindApprovedStreamerForUser(userID string) (*model.WzDocument, error) {
ctx, cancel := mongoCtx()
defer cancel()
cur, err := mcoll(CollStreamers).Find(ctx, bson.M{"userId": userID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
for cur.Next(ctx) {
var raw bson.M
if err := cur.Decode(&raw); err != nil {
continue
}
d, err := rawDocToWzDocument(CollStreamers, raw)
if err != nil {
continue
}
m, _ := BodyMap(&d)
if strFromMap(m, "status") == "approved" {
return &d, nil
}
}
return nil, ErrNotFound
}
func mongoFindLiveRoomByStreamerID(streamerExtID string) (*model.WzDocument, error) {
return mongoFindByField(CollLiveRooms, "streamerId", streamerExtID)
}
func mongoCountBindingsForStreamer(streamerExtID string) (int64, error) {
ctx, cancel := mongoCtx()
defer cancel()
return mcoll(CollStreamerBindings).CountDocuments(ctx, bson.M{"streamerId": streamerExtID})
}
func mongoListBindingsForStreamer(streamerExtID string) ([]model.WzDocument, error) {
ctx, cancel := mongoCtx()
defer cancel()
cur, err := mcoll(CollStreamerBindings).Find(ctx, bson.M{"streamerId": streamerExtID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var out []model.WzDocument
for cur.Next(ctx) {
var raw bson.M
if err := cur.Decode(&raw); err != nil {
continue
}
d, err := rawDocToWzDocument(CollStreamerBindings, raw)
if err != nil {
continue
}
out = append(out, d)
}
return out, cur.Err()
}
func mongoCountJourneyDedup(sessionID, clientID string) (int64, error) {
ctx, cancel := mongoCtx()
defer cancel()
return mcoll(CollUserJourneyEvents).CountDocuments(ctx, bson.M{
"sessionId": sessionID,
"clientEventId": clientID,
})
}
func mongoCountActiveBindingsForUser(userID string) (int64, error) {
ctx, cancel := mongoCtx()
defer cancel()
return mcoll(CollStreamerBindings).CountDocuments(ctx, bson.M{
"userId": userID,
"status": "active",
})
}

View File

@@ -0,0 +1,34 @@
/**
* 本地 Docker Mongowanzhi-mongodb 等)练手:创建库 + users 集合示例文档。
*
* 连接(与容器环境变量 MONGO_INITDB_ROOT_* 一致时):
* mongosh "mongodb://admin:admin123@127.0.0.1:27017/wanzhi_local?authSource=admin"
*
* 在 mongosh 内执行:
* load("e:/.../new/wz-api/scripts/mongo-local-practice.js")
*
* 说明wz-api 管理端「玩值用户列表」读的是 MySQL 表 wz_documentscoll='users'
* 不会读本脚本写入的 Mongo 集合;本脚本仅用于熟悉 Mongo / Compass / 与旧栈集合名对齐。
*/
const dbName = "wanzhi_local";
const collName = "users";
db = db.getSiblingDB(dbName);
db[collName].insertMany([
{
name: "练习用户",
phone: "13800138000",
// 以下为文档字段示意;玩值 HTTP 登录不走 Mongo 此密码
loginUsername: "admin",
loginPasswordPlain: "admin123",
status: "active",
isVip: false,
balance: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
print(`OK: ${dbName}.${collName} count = ${db[collName].countDocuments({})}`);

View File

@@ -0,0 +1,41 @@
-- 管理端 wz-adminPOST /api/admin 登录账号username + password
-- 代码优先查 MySQL `admin_users`,使用 bcrypt 校验 `password_hash`。
-- 默认口令 admin123 的哈希由 golang.org/x/crypto/bcrypt DefaultCost 生成(与 AdminLogin 一致)。
--
-- 在 .env.development 指向的库执行(示例为 souldev请按实际 DB 名修改 USE
-- 若 SKIP_AUTO_MIGRATE=1 且从未全量迁库,可能尚无 admin_users 表,下面含 CREATE IF NOT EXISTS。
USE souldev;
CREATE TABLE IF NOT EXISTS admin_users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(128) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'admin',
name VARCHAR(64) DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NULL,
updated_at DATETIME(3) NULL,
deleted_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY idx_admin_users_username (username),
KEY idx_admin_users_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO admin_users (username, password_hash, role, name, status, created_at, updated_at)
VALUES (
'admin',
'$2a$10$f1SdpLVBRhy7hbq6XqmE5OSauzFg1etbCDBdsyIiKxc.Pm7ETBkBq',
'super_admin',
'本地练习',
'active',
UTC_TIMESTAMP(3),
UTC_TIMESTAMP(3)
)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
role = VALUES(role),
status = VALUES(status),
name = VALUES(name),
updated_at = UTC_TIMESTAMP(3),
deleted_at = NULL;

View File

@@ -0,0 +1,17 @@
-- 玩值管理端 GET /api/admin/wz/users数据来自 MySQL `wz_documents`,其中 coll = 'users'
-- body 为 JSON与旧 Mongo 集合 users 迁移策略一致)。
-- 在云库 souldev或你的 DB执行表须已存在SKIP_AUTO_MIGRATE=1 时启动会尝试 AutoMigrate WzDocument
USE souldev;
INSERT INTO wz_documents (`coll`, `ext_id`, `body`, `created_at`, `updated_at`)
VALUES (
'users',
'localdevuser000000000001',
CAST('{"name":"列表练习用户","phone":"13900139000","status":"active","isVip":false,"balance":88,"createdAt":"2026-04-29T00:00:00Z","updatedAt":"2026-04-29T00:00:00Z"}' AS JSON),
UTC_TIMESTAMP(3),
UTC_TIMESTAMP(3)
)
ON DUPLICATE KEY UPDATE
`body` = VALUES(`body`),
`updated_at` = UTC_TIMESTAMP(3);

Binary file not shown.

View File

@@ -1,4 +1,6 @@
# 须与 new/wz-api 的 PORT 一致(本仓库 .env.development 默认 48080
WZ_API_PROXY_TARGET=http://127.0.0.1:48080
# 可填完整根地址以绕过代理直连(通常不必)
VITE_API_BASE_URL=https://wzapi.quwanzhi.com
# 须与 new/wz-api 的 PORT 一致(与生产一致,默认 4040
WZ_API_PROXY_TARGET=http://127.0.0.1:4040
# 本地 / 手机连电脑 IP 调试:不要填 VITE_API_BASE_URL保持走当前站点同源 /api → Vite 代理到本机 wz-api。
# 仅当刻意直连某套后端时取消注释并改为完整根地址(无尾斜杠),例如:
# VITE_API_BASE_URL=https://wzapi.quwanzhi.com

View File

@@ -1,8 +1,9 @@
# 生产/预览:填写 wz-api 根地址,无尾斜杠
#
# 开发WZ_API_PROXY_TARGET 须与 new/wz-api 的 PORT 一致(本仓库默认 48080
WZ_API_PROXY_TARGET=http://127.0.0.1:48080
# 留空则走 /api 代理;直连远端或本机 API 时填写完整根地址,如 https://wzapi.quwanzhi.com
VITE_API_BASE_URL=https://wzapi.quwanzhi.com
# 开发WZ_API_PROXY_TARGET 须与 new/wz-api 的 PORT 一致(本仓库默认 4040与生产 listen 一致
WZ_API_PROXY_TARGET=http://127.0.0.1:4040
# 开发默认留空/不设置:浏览器请求同源 /api由 Vite 代理到 WZ_API_PROXY_TARGET。
# 预览或直连线上时才填,例如:
# VITE_API_BASE_URL=https://wzapi.quwanzhi.com
# 可选别名,与 VITE_API_BASE_URL 二选一
# VITE_WZ_API_BASE=

View File

@@ -9,14 +9,15 @@
## 本地开发(必读,避免登录 /api 502
1. **先启动后端** `new/wz-api`(需 `DB_DSN` 等;**`PORT``new/wz-api/.env.development`本仓库默认 `48080`**)。可访问 `http://127.0.0.1:48080/health` 确认存活。
1. **先启动后端** `new/wz-api`(需 `DB_DSN` 等;**`PORT``new/wz-api/.env.development`与生产一致默认 `4040`**)。可访问 `http://127.0.0.1:4040/health` 确认存活。
2. 本目录执行 `npm install`**`npm run dev`**Vite 默认 5173
3. 前端请求 `/api/...` 由 Vite 代理到 **`WZ_API_PROXY_TARGET`****必须与 wz-api 的 `PORT` 一致**;与仓库默认对齐为 **48080**
4. **手机/局域网**访问 dev使用 `npm run dev`(已配置 `--host` 时见 `package.json`),用电脑 IP + `:5173`(或终端里给出的端口如 `5174`)打开;此时 `/api` 仍由运行 Vite 的电脑本机代理到 `127.0.0.1:PORT`(若手机需直连 wz-api可设 `VITE_API_BASE_URL=http://电脑IP:PORT` 并保证 wz-api 监听 `0.0.0.0`、防火墙放行 **同一 PORT**)。
3. `.env.development` **`VITE_API_BASE_URL` / `VITE_WZ_API_BASE` 请留空**(或不写),让浏览器走**同源** `/api` → Vite 代理;若填成线上 `https://wzapi...`,登录/注册会打到**远端**而非本机 wz-api
4. 前端请求 `/api/...` 由 Vite 代理到 **`WZ_API_PROXY_TARGET`****必须与 wz-api 的 `PORT` 一致**;与仓库默认对齐为 **4040**)。
5. **手机/局域网**访问 dev使用 `npm run dev`(已配置 `--host` 时见 `package.json`),用电脑 IP + `:5173`(或终端里给出的端口如 `5174`)打开;此时 `/api` 仍由运行 Vite 的电脑本机代理到 `127.0.0.1:PORT`(若手机需直连 wz-api可设 `VITE_API_BASE_URL=http://电脑IP:PORT` 并保证 wz-api 监听 `0.0.0.0`、防火墙放行 **同一 PORT**)。
5. **`GET /api/app/mall/point-cards` 在浏览器里 404**:先**绕过 Vite**访问后端根地址(端口与 wz-api 的 `PORT` 一致,默认 `48080``http://127.0.0.1:48080/api/app/mall/point-cards`
6. **`GET /api/app/mall/point-cards` 在浏览器里 404**:先**绕过 Vite**访问后端根地址(端口与 wz-api 的 `PORT` 一致,默认 `4040``http://127.0.0.1:4040/api/app/mall/point-cards`
- 若该直连仍是 **404**,而 **`GET /api/app/streamers` 为 200**,说明当前正在跑的 wz-api **可执行文件过旧、未编进点卡路由**,与仓库 `new/wz-api` 不一致;请在 `new/wz-api` 执行 `go build -o 你的路径/wz-api.exe ./cmd/server``go run ./cmd/server`**停掉旧进程后**用新构建重启。
- 若直连为 **502/连不上** 而前端 404多为未启动 wz-api 或 `WZ_API_PROXY_TARGET` 与 wz-api `PORT` 不一致(见上第 1、3 步)。
- 若直连为 **502/连不上** 而前端 404多为未启动 wz-api 或 `WZ_API_PROXY_TARGET` 与 wz-api `PORT` 不一致(见上第 1、4 步)。
与请求封装、错误提示:`src/lib/request.ts``src/lib/config.ts`

View File

@@ -11,7 +11,7 @@
rel="stylesheet"
/>
<title>玩值电竞 - WanZhi Esports</title>
<script type="module" crossorigin src="/assets/index-mBioFLhP.js"></script>
<script type="module" crossorigin src="/assets/index-CnJ4AXBQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CeISOfIA.css">
</head>
<body class="min-h-screen antialiased">

View File

@@ -119,11 +119,22 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
try {
const r = await appAuthLogin(phone, password)
const d = r.data
if (d?.token) setToken(d.token)
if (!d?.token) {
toast({ title: "登录失败", description: "未返回 token请检查 wz-api 与网络" })
return false
}
setToken(d.token)
setAuthChecked(false)
await syncFromDatabase()
if (!getToken()) {
return false
}
return true
} catch {
} catch (e) {
toast({
title: "登录失败",
description: e instanceof Error ? e.message : "请核对手机号与密码",
})
return false
}
},

View File

@@ -1,18 +1,22 @@
/**
* 与 [new/wz-app/src/utils/config.js] 一致:
* - 优先 `VITE_API_BASE_URL`,其次 `VITE_WZ_API_BASE`(计划中的命名);
* - 开发时若未配置:用空串走 **Vite 代理** `/api` → wz-api避免 CORS
* - 非开发且未配置:回退 `http://127.0.0.1:48080`(与 new/wz-api `.env.development` 中 PORT 一致;`vite preview` 直连 API 时用)。
* - 优先 `VITE_API_BASE_URL`,其次 `VITE_WZ_API_BASE`(无尾斜杠);
* - **开发 (vite dev)**:若两变量均为空,返回 `''`,请求走**同源** `/api` → Vite 代理到本机 wz-api避免误配线上基址导致登录打远端
* - **构建预览 / 生产**:未配置时回退 `http://127.0.0.1:4040`(与 wz-api 默认监听一致)。
*/
const WZ_DEFAULT_BASE = "http://127.0.0.1:48080"
const WZ_DEFAULT_BASE = "http://127.0.0.1:4040"
function trimmedEnv(name: "VITE_API_BASE_URL" | "VITE_WZ_API_BASE"): string {
const raw = import.meta.env[name]
if (raw === undefined || raw === null) return ""
const s = String(raw).trim()
return s
}
export function getApiBase(): string {
const a = import.meta.env.VITE_API_BASE_URL
const b = import.meta.env.VITE_WZ_API_BASE
const a = trimmedEnv("VITE_API_BASE_URL")
const b = trimmedEnv("VITE_WZ_API_BASE")
for (const v of [a, b]) {
if (v !== undefined && String(v).trim() !== "") {
return String(v).replace(/\/$/, "")
}
if (v !== "") return v.replace(/\/$/, "")
}
if (import.meta.env.DEV) return ""
return WZ_DEFAULT_BASE

View File

@@ -54,7 +54,7 @@ export async function request<T = unknown>(path: string, options: RequestOptions
if (!res.ok) {
if (res.status === 502 || res.status === 503 || res.status === 504) {
throw new Error(
"玩值接口无响应。请启动 new/wz-api端口须与 WZ_API_PROXY_TARGET 一致,本仓库默认 48080并核对 Vite 代理。",
"玩值接口无响应。请启动 new/wz-api端口须与 WZ_API_PROXY_TARGET 一致,本仓库默认 4040并核对 Vite 代理。",
)
}
throw new Error(`HTTP ${res.status}`)
@@ -75,7 +75,7 @@ export async function request<T = unknown>(path: string, options: RequestOptions
if (!res.ok) {
if (res.status === 502 || res.status === 503 || res.status === 504) {
throw new Error(
"玩值接口无响应(网关错误)。请启动 `new/wz-api`,并保证 `new/wz-app/.env.development` 里 WZ_API_PROXY_TARGET 与 wz-api 的 PORT 一致(本仓库默认 48080。",
"玩值接口无响应(网关错误)。请启动 `new/wz-api`,并保证 `new/wz-app/.env.development` 里 WZ_API_PROXY_TARGET 与 wz-api 的 PORT 一致(本仓库默认 4040。",
)
}
const msg =

View File

@@ -8,8 +8,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
// `process.env.XXX` 在 vite 配置中不会自动读 .env需 loadEnv否则改 .env 不生效、易出现代理 502
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "")
// 与 new/wz-api 开发默认 PORT=48080.env.development一致
const apiTarget = (env.WZ_API_PROXY_TARGET || "http://127.0.0.1:48080").replace(/\/$/, "")
// 与 new/wz-api 开发/生产默认 PORT=4040.env.development / master.py)一致
const apiTarget = (env.WZ_API_PROXY_TARGET || "http://127.0.0.1:4040").replace(/\/$/, "")
return {
plugins: [react()],

View File

@@ -3,6 +3,11 @@
约定Next 中文件 `app/api/<segments>/route.ts` 对应 URL 为 `/api/<segments>`(动态段为 `[param]`)。新路径列遵循 [迁移-API前缀决议.md](迁移-API前缀决议.md)。**玩值管理端**与 Soul 历史路由隔离,统一 **`/api/admin/wz/*`**。
**玩值 C 端**新功能以 **`new/wz-app` + wz-api `/api/app/*`** 为真源;**`old/app`** 与下表「old URL」列用于 **溯源与漏项对照**,不在 `old` 扩新业务能力。
**Mongo-onlywz-api 当前默认二进制)**
- **仍挂载**`/api/app/*``/api/screen/*``/api/public/build-info``POST/GET /api/admin`(登录与鉴权检查)、`/api/admin/auth/*`、**`/api/admin/wz/*`**(及 `RegisterWanzhiAdminRoutes` 下与 old Next 对齐的 mock 路径)。
- **已自该构建移除(未恢复则用不到)**:全体 **`/api/miniprogram/*`**、**`/api/db/*`**、书籍/章节/支付/提现/匹配等原 Soul 路由;**`GET /read/:id`**、**`GET /ws/miniprogram`**。若需上述能力须另开带 MySQL 的 legacy 构建或重新实现为 Mongo。
- **数据**:玩值集合以 Mongo **`ext_id`** 为主键字段C 端账号在 **`app_users`** / **`user_auths`**,勿与玩值列表集合 **`users`** 混淆。详见 `new/wz-api/MIGRATION.md`
**状态**`未实现` | `进行中` | `已实现` | `废弃`
| # | old URL逻辑路径 | 主要调用方 | 新 wz-api 路径 | 方法 | 状态 |