diff --git a/.gitignore b/.gitignore
index 949a4ec..f079246 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,6 +79,7 @@ desktop.ini
.dotnet/
.nuget/
.userdata/
+dotnet-sdk/
# ===================== Docker =====================
docker-compose.override.yml
diff --git a/auth-service b/auth-service
new file mode 100644
index 0000000..71e080e
Binary files /dev/null and b/auth-service differ
diff --git a/launcher/MRCC.Launcher/App.xaml.cs b/launcher/MRCC.Launcher/App.xaml.cs
index b9a5d57..b99f7d3 100644
--- a/launcher/MRCC.Launcher/App.xaml.cs
+++ b/launcher/MRCC.Launcher/App.xaml.cs
@@ -4,4 +4,11 @@ namespace MRCC.Launcher;
public partial class App : Application
{
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+ DispatcherUnhandledException += (s, a) => Program.Log($"UI异常: {a.Exception}");
+ AppDomain.CurrentDomain.UnhandledException += (s, a) => Program.Log($"未处理异常: {a.ExceptionObject}");
+ TaskScheduler.UnobservedTaskException += (s, a) => Program.Log($"任务异常: {a.Exception}");
+ }
}
diff --git a/launcher/MRCC.Launcher/GameWindow.xaml b/launcher/MRCC.Launcher/GameWindow.xaml
new file mode 100644
index 0000000..c4e95bc
--- /dev/null
+++ b/launcher/MRCC.Launcher/GameWindow.xaml
@@ -0,0 +1,5 @@
+
+
diff --git a/launcher/MRCC.Launcher/GameWindow.xaml.cs b/launcher/MRCC.Launcher/GameWindow.xaml.cs
new file mode 100644
index 0000000..f744425
--- /dev/null
+++ b/launcher/MRCC.Launcher/GameWindow.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows;
+
+namespace MRCC.Launcher;
+
+public partial class GameWindow : Window
+{
+ public GameWindow()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/launcher/MRCC.Launcher/MRCC.Launcher.csproj b/launcher/MRCC.Launcher/MRCC.Launcher.csproj
index f7c8643..39ad81b 100644
--- a/launcher/MRCC.Launcher/MRCC.Launcher.csproj
+++ b/launcher/MRCC.Launcher/MRCC.Launcher.csproj
@@ -1,31 +1,25 @@
-
WinExe
net10.0-windows
true
true
MRCC.Launcher
- MRCC.Launcher
+ RedCircuit
+ ..\..\build\
enable
14.0
enable
app.manifest
- MRCC.Launcher.Program
+ Program
+ true
+ false
+ true
-
-
+
-
-
+
-
-
-
- PCL.metadata.json
-
-
-
diff --git a/launcher/MRCC.Launcher/MainWindow.xaml b/launcher/MRCC.Launcher/MainWindow.xaml
index f0fc1a7..f0e5d02 100644
--- a/launcher/MRCC.Launcher/MainWindow.xaml
+++ b/launcher/MRCC.Launcher/MainWindow.xaml
@@ -1,128 +1,11 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ Background="#0A0A14"
+ ResizeMode="CanResizeWithGrip"
+ MinHeight="400" MinWidth="600"
+ Loaded="OnWindowLoaded">
diff --git a/launcher/MRCC.Launcher/MainWindow.xaml.cs b/launcher/MRCC.Launcher/MainWindow.xaml.cs
index 82d13c4..2e650ec 100644
--- a/launcher/MRCC.Launcher/MainWindow.xaml.cs
+++ b/launcher/MRCC.Launcher/MainWindow.xaml.cs
@@ -1,29 +1,155 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Text.Json;
+using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
+using Microsoft.Web.WebView2.Core;
+using Microsoft.Web.WebView2.Wpf;
namespace MRCC.Launcher;
public partial class MainWindow : Window
{
+ private WebView2? _wv;
+
public MainWindow()
{
InitializeComponent();
}
- private void OnNavChecked(object sender, RoutedEventArgs e)
+ private async void OnWindowLoaded(object sender, RoutedEventArgs e)
{
- if (sender is not RadioButton rb) return;
- var tag = rb.Tag?.ToString() ?? "home";
+ try
+ {
+ var gameDir = FindGamePath();
+ if (gameDir == null)
+ {
+ Content = new TextBlock { Text = "游戏文件未找到", Foreground = System.Windows.Media.Brushes.White, FontSize = 14, Margin = new Thickness(20) };
+ return;
+ }
- PageHome.Visibility = tag == "home" ? Visibility.Visible : Visibility.Collapsed;
- PageDownload.Visibility = tag == "download" ? Visibility.Visible : Visibility.Collapsed;
- PageSettings.Visibility = tag == "settings" ? Visibility.Visible : Visibility.Collapsed;
- PageAbout.Visibility = tag == "about" ? Visibility.Visible : Visibility.Collapsed;
+ var port = GameServer.Start(gameDir);
+ var url = $"http://localhost:{port}/voxel-world.html";
+
+ _wv = new WebView2();
+ Content = _wv;
+
+ await _wv.EnsureCoreWebView2Async();
+ _wv.CoreWebView2.Settings.IsWebMessageEnabled = true;
+ _wv.CoreWebView2.WebMessageReceived += OnWebMessage;
+
+ _wv.CoreWebView2.Navigate(url);
+ Program.Log($"Launcher UI loaded: {url}");
+ }
+ catch (Exception ex)
+ {
+ Program.Log($"Launcher load failed: {ex}");
+ }
}
- private void OnLaunchGame(object sender, RoutedEventArgs e)
+ private void OnWebMessage(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
- // TODO: 启动 MRCC Unity 客户端
- MessageBox.Show("游戏启动功能开发中", "MRCC Launcher", MessageBoxButton.OK, MessageBoxImage.Information);
+ try
+ {
+ // WebView2 的 postMessage 会 JSON 编码,需先解一层
+ var rawJson = e.TryGetWebMessageAsString();
+ if (string.IsNullOrEmpty(rawJson)) return;
+ using var doc = JsonDocument.Parse(rawJson);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("action", out var action) || action.GetString() != "launch_game") return;
+
+ var username = "Player";
+ if (root.TryGetProperty("username", out var u) && u.ValueKind == JsonValueKind.String)
+ username = u.GetString()!;
+ // 不再启动 Luanti —— HTML5 世界直接在 WebView2 内运行
+ Program.Log($"Game launch requested by: {username}");
+ }
+ catch (Exception ex) { Program.Log($"WebMessage error: {ex}"); }
+ }
+
+ public static string? FindGamePath()
+ {
+ var exeDir = AppContext.BaseDirectory;
+ foreach (var p in new[] { "game", "../game", "../../../prototype", "../../prototype", "../prototype" })
+ {
+ var full = Path.GetFullPath(Path.Combine(exeDir, p));
+ if (Directory.Exists(full) && File.Exists(Path.Combine(full, "voxel-world.html")))
+ return full;
+ }
+ return null;
+ }
+}
+
+// HTTP server for static files
+public static class GameServer
+{
+ private static HttpListener? _listener;
+ private static CancellationTokenSource? _cts;
+ private static string _gameDir = "";
+
+ public static int Start(string gameDir)
+ {
+ _gameDir = gameDir;
+ var port = FindFreePort();
+ _cts = new CancellationTokenSource();
+ _listener = new HttpListener();
+ _listener.Prefixes.Add($"http://localhost:{port}/");
+ _listener.Start();
+ Task.Run(() => ListenLoop(_cts.Token));
+ return port;
+ }
+
+ public static void Stop()
+ {
+ _cts?.Cancel();
+ try { _listener?.Stop(); } catch { }
+ try { _listener?.Close(); } catch { }
+ }
+
+ private static async Task ListenLoop(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try { var ctx = await _listener!.GetContextAsync().WaitAsync(ct); _ = Task.Run(() => Handle(ctx)); }
+ catch (OperationCanceledException) { break; }
+ catch (HttpListenerException) { break; }
+ }
+ }
+
+ private static async Task Handle(HttpListenerContext ctx)
+ {
+ try
+ {
+ var path = ctx.Request.Url!.AbsolutePath.TrimStart('/');
+ if (string.IsNullOrEmpty(path)) path = "voxel-world.html";
+ var fp = Path.GetFullPath(Path.Combine(_gameDir, path));
+ if (!fp.StartsWith(_gameDir, StringComparison.OrdinalIgnoreCase) || !File.Exists(fp))
+ { ctx.Response.StatusCode = 404; ctx.Response.Close(); return; }
+
+ var ct = Path.GetExtension(fp).ToLowerInvariant() switch
+ {
+ ".html" => "text/html; charset=utf-8", ".css" => "text/css",
+ ".js" => "application/javascript", ".json" => "application/json",
+ ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg",
+ ".svg" => "image/svg+xml", _ => "application/octet-stream"
+ };
+ var bytes = await File.ReadAllBytesAsync(fp);
+ ctx.Response.ContentType = ct;
+ ctx.Response.ContentLength64 = bytes.Length;
+ ctx.Response.Headers.Add("Access-Control-Allow-Origin", "*");
+ ctx.Response.Headers.Add("Cache-Control", "no-cache");
+ await ctx.Response.OutputStream.WriteAsync(bytes);
+ ctx.Response.Close();
+ }
+ catch { try { ctx.Response.StatusCode = 500; ctx.Response.Close(); } catch { } }
+ }
+
+ private static int FindFreePort()
+ {
+ var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ l.Start(); var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port; l.Stop();
+ return port;
}
}
diff --git a/launcher/MRCC.Launcher/Program.cs b/launcher/MRCC.Launcher/Program.cs
index d4c0f80..befeefb 100644
--- a/launcher/MRCC.Launcher/Program.cs
+++ b/launcher/MRCC.Launcher/Program.cs
@@ -1,26 +1,50 @@
using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
using System.Windows;
-using PCL.Core.App.Essentials;
-using PCL.Core.App.IoC;
+using MRCC.Launcher;
-namespace MRCC.Launcher;
-
-public static class Program
+internal static class Program
{
+ public static bool IsDevMode;
+ private static readonly object _logLock = new();
+ private static readonly string LogPath = "redcircuit.log";
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool AllocConsole();
+
[STAThread]
public static void Main(string[] args)
{
- // 设置 WPF Application 加载委托
- ApplicationService.Loading = () =>
+ IsDevMode = args.Any(a => string.Equals(a, "dev", StringComparison.OrdinalIgnoreCase));
+
+ if (IsDevMode)
{
- var app = new App();
- return app;
- };
+ AllocConsole();
+ Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
+ Log("=== RedCircuit 开发模式 ===");
+ Log($"工作目录: {Environment.CurrentDirectory}");
+ Log($"EXE目录: {AppContext.BaseDirectory}");
+ Log("HTML5 世界直接运行于 WebView2 内,控制台用于查看日志。");
+ }
- // 设置主窗口加载委托
- MainWindowService.Loading = () => new MainWindow();
+ // 启动器 UI:WebView2 加载 HTML5 世界
+ var app = new App();
+ app.Run(new MainWindow());
+ }
- // 启动生命周期
- Lifecycle.OnInitialize();
+ public static void Log(string message)
+ {
+ var line = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
+ lock (_logLock)
+ {
+ if (IsDevMode) Console.WriteLine(line);
+ Debug.WriteLine(line);
+ try { File.AppendAllText(LogPath, line + Environment.NewLine, Encoding.UTF8); } catch { }
+ }
}
}
diff --git a/launcher/MRCC.Launcher/ViewModels/MainViewModel.cs b/launcher/MRCC.Launcher/ViewModels/MainViewModel.cs
deleted file mode 100644
index ed09556..0000000
--- a/launcher/MRCC.Launcher/ViewModels/MainViewModel.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using CommunityToolkit.Mvvm.ComponentModel;
-
-namespace MRCC.Launcher.ViewModels;
-
-///
-/// 主窗口 ViewModel,管理导航状态与页面数据。
-///
-public partial class MainViewModel : ObservableObject
-{
- [ObservableProperty]
- private string _versionText = "v1.0.0-dev";
-
- [ObservableProperty]
- private string _statusText = "就绪";
-
- [ObservableProperty]
- private bool _isGameInstalled;
-
- [ObservableProperty]
- private int _downloadProgress;
-
- ///
- /// 启动游戏命令(后续实现具体逻辑)
- ///
- public void LaunchGame()
- {
- // TODO: 检查游戏安装状态 -> 启动 Unity 客户端
- }
-}
diff --git a/prototype/background.jpg b/prototype/background.jpg
new file mode 100644
index 0000000..355a21b
Binary files /dev/null and b/prototype/background.jpg differ
diff --git a/prototype/textures/ui/store.jpeg b/prototype/textures/ui/store.jpeg
new file mode 100644
index 0000000..1c1a1b6
Binary files /dev/null and b/prototype/textures/ui/store.jpeg differ
diff --git a/prototype/voxel-world.html b/prototype/voxel-world.html
index d86d692..a0a1969 100644
--- a/prototype/voxel-world.html
+++ b/prototype/voxel-world.html
@@ -2,69 +2,682 @@
@@ -80,14 +693,184 @@ canvas{display:block}
+
-
RedCircuit
-
红石回路 · 方块世界原型
-
-
WASD 移动 · 空格跳跃 · 鼠标视角
左键破坏 · 右键放置 · 数字 1-9 快捷栏
E 打开背包 · ESC 暂停
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
AI 助手你好!我是 RedCircuit AI 助手。
我可以帮你解答电路问题、推荐搭建方案。
试试问我:"如何搭建一个 AND 门?"
+
+
+
+
+
+
F11 释放鼠标 · 按钮在右下角
+
+
diff --git a/prototype/webgl-test.html b/prototype/webgl-test.html
new file mode 100644
index 0000000..88b3fd1
--- /dev/null
+++ b/prototype/webgl-test.html
@@ -0,0 +1,131 @@
+
+
WebGL Test
+
+
WebView2 / WebGL 完整诊断
+
+
+
+
diff --git a/redcircuit.log b/redcircuit.log
new file mode 100644
index 0000000..96c7565
--- /dev/null
+++ b/redcircuit.log
@@ -0,0 +1,23 @@
+[23:51:26.073] === RedCircuit 开发者模式 ===
+[23:51:26.099] 工作目录: D:\Code\MRCC
+[23:51:26.104] EXE目录: D:\Code\MRCC\build\
+[23:51:26.161] 游戏目录: D:\Code\MRCC\build\game
+[23:51:26.194] HTTP Server: http://localhost:55698/
+[23:51:26.205] 游戏URL: http://localhost:55698/voxel-world.html
+[23:51:26.336] 游戏窗口已创建
+[23:51:26.648] === 游戏窗口 OnLoaded ===
+[23:51:26.649] URL: http://localhost:55698/voxel-world.html
+[23:51:26.652] WebView2 Runtime: 148.0.3967.96
+[23:51:26.652] 初始化 WebView2...
+[23:51:26.917] WebView2 初始化完成
+[23:51:26.918] 导航到: http://localhost:55698/voxel-world.html
+[23:51:26.944] 导航开始: http://localhost:55698/voxel-world.html
+[23:51:27.205] 导航完成: HTTP 200
+[23:51:27.207] 注入监控脚本...
+[23:51:27.224] 监控脚本已注入
+[23:51:29.269] [JS] "THREE:loaded"
+[23:51:29.270] [JS] "WEBGL:available"
+[23:51:29.281] [JS] "WEBGL_CTX:created"
+[23:51:32.261] [JS] "WORLD:meshes=N/A scene_children=17"
+[23:51:32.263] [JS] "FALLBACK:ground_added"
+[23:51:50.952] 游戏窗口关闭,停止HTTP服务器
diff --git a/server/cmd/auth-service/main.go b/server/cmd/auth-service/main.go
index 9b11b02..db2afcf 100644
--- a/server/cmd/auth-service/main.go
+++ b/server/cmd/auth-service/main.go
@@ -2,7 +2,9 @@ package main
import (
"log"
+ "os"
+ "mrcc/internal/auth"
"mrcc/internal/config"
"mrcc/internal/httpserver"
"mrcc/internal/logger"
@@ -14,12 +16,25 @@ func main() {
srv := httpserver.New(cfg)
- // TODO: 注册认证路由
- // r := srv.Group("/api/auth")
- // r.POST("/register", ...)
- // r.POST("/login", ...)
+ // JWT 密钥:默认开发密钥,生产环境通过 AUTH_JWT_SECRET 覆盖
+ secret := os.Getenv("AUTH_JWT_SECRET")
+ if secret == "" {
+ secret = "mrcc-dev-secret-change-me"
+ }
- log.Printf("auth-service listening on %s", cfg.Address())
+ store := auth.NewUserStore()
+ jwtMgr := auth.NewJWTManager(secret)
+ h := auth.NewHandler(store, jwtMgr)
+
+ // 认证路由: /api/auth/register|login|me
+ authGroup := srv.Group("/api/auth")
+ h.RegisterAuthRoutes(authGroup)
+
+ // 经济路由: /api/daily-reward|spend|buy|use-item
+ apiGroup := srv.Group("/api")
+ h.RegisterEconomyRoutes(apiGroup)
+
+ log.Printf("auth-service listening on %s (endpoints: /api/auth/*, /api/daily-reward ...)", cfg.Address())
if err := srv.Run(cfg.Address()); err != nil {
log.Fatal(err)
}
diff --git a/server/go.mod b/server/go.mod
index 5554de8..3d1a358 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -4,7 +4,9 @@ go 1.22
require (
github.com/gin-gonic/gin v1.10.0
+ github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
+ golang.org/x/crypto v0.23.0
)
require (
@@ -22,13 +24,12 @@ require (
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
- golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
diff --git a/server/go.sum b/server/go.sum
new file mode 100644
index 0000000..5fd0e25
--- /dev/null
+++ b/server/go.sum
@@ -0,0 +1,93 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go
new file mode 100644
index 0000000..b7ab449
--- /dev/null
+++ b/server/internal/auth/auth.go
@@ -0,0 +1,324 @@
+package auth
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// User 用户数据
+type User struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ PasswordHash string `json:"-"` // 不返回给客户端
+ CreatedAt int64 `json:"created_at"`
+ Level int `json:"level"`
+ Exp int `json:"exp"`
+ Diamonds int `json:"diamonds"`
+ RedstoneCoins int64 `json:"redstone_coins"`
+ GoldCoins int `json:"gold_coins"`
+ LastLoginDate string `json:"last_login_date"`
+ Inventory map[string]int `json:"inventory"` // itemId → count
+ EquippedItem string `json:"equipped_item"` // 当前装备的道具ID
+ SpeedBoostUntil int64 `json:"speed_boost_until"` // 速度加成到期时间(unix)
+ PurchaseHistory []PurchaseRecord `json:"purchase_history"` // 最近50条购买记录
+}
+
+// PurchaseRecord 购买记录
+type PurchaseRecord struct {
+ ItemID string `json:"item_id"`
+ ItemName string `json:"item_name"`
+ Currency string `json:"currency"`
+ Amount int64 `json:"amount"`
+ Timestamp int64 `json:"timestamp"`
+}
+
+// UserStore 内存用户存储 (后续可替换为 PostgreSQL)
+type UserStore struct {
+ mu sync.RWMutex
+ users map[string]*User // key = username (lowercase)
+}
+
+func NewUserStore() *UserStore {
+ return &UserStore{users: make(map[string]*User)}
+}
+
+func (s *UserStore) Create(username, password string) (*User, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if _, exists := s.users[username]; exists {
+ return nil, errors.New("用户名已存在")
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, fmt.Errorf("密码加密失败: %w", err)
+ }
+
+ now := time.Now()
+ user := &User{
+ ID: fmt.Sprintf("u_%d", now.UnixNano()),
+ Username: username,
+ PasswordHash: string(hash),
+ CreatedAt: now.Unix(),
+ Level: 1,
+ Exp: 0,
+ Diamonds: 20,
+ RedstoneCoins: 3000,
+ GoldCoins: 1,
+ LastLoginDate: "", // 留空:注册当天可正常签到领奖
+ Inventory: make(map[string]int),
+ PurchaseHistory: []PurchaseRecord{},
+ }
+ s.users[username] = user
+ return user, nil
+}
+
+func (s *UserStore) GetByUsername(username string) (*User, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ u, ok := s.users[username]
+ return u, ok
+}
+
+func (s *UserStore) GetByID(id string) (*User, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ for _, u := range s.users {
+ if u.ID == id {
+ return u, true
+ }
+ }
+ return nil, false
+}
+
+func (s *UserStore) VerifyPassword(username, password string) (*User, error) {
+ user, ok := s.GetByUsername(username)
+ if !ok {
+ return nil, errors.New("用户不存在")
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
+ return nil, errors.New("密码错误")
+ }
+ return user, nil
+}
+
+// DailyLogin 每日登录奖励。返回奖励详情和是否已领取。
+func (s *UserStore) DailyLogin(userID string) (map[string]interface{}, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ var user *User
+ for _, u := range s.users {
+ if u.ID == userID {
+ user = u
+ break
+ }
+ }
+ if user == nil {
+ return nil, errors.New("用户不存在")
+ }
+
+ today := time.Now().Format("2006-01-02")
+ if user.LastLoginDate == today {
+ return map[string]interface{}{
+ "claimed": true,
+ "message": "今日已领取",
+ }, nil
+ }
+
+ // 发放奖励
+ const rewardDiamonds = 20
+ const rewardRedstoneCoins = 3000
+ const rewardGoldCoins = 1
+
+ user.Diamonds += rewardDiamonds
+ user.RedstoneCoins += rewardRedstoneCoins
+ user.GoldCoins += rewardGoldCoins
+ user.LastLoginDate = today
+
+ return map[string]interface{}{
+ "claimed": false,
+ "message": "领取成功",
+ "diamonds": rewardDiamonds,
+ "redstone": rewardRedstoneCoins,
+ "gold": rewardGoldCoins,
+ "total_diamonds": user.Diamonds,
+ "total_redstone": user.RedstoneCoins,
+ "total_gold": user.GoldCoins,
+ }, nil
+}
+
+// SpendCurrency 消费货币,返回成功或余额不足错误
+func (s *UserStore) SpendCurrency(userID, currency string, amount int64) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ var user *User
+ for _, u := range s.users {
+ if u.ID == userID {
+ user = u
+ break
+ }
+ }
+ if user == nil {
+ return errors.New("用户不存在")
+ }
+
+ switch currency {
+ case "diamonds":
+ if user.Diamonds < int(amount) {
+ return fmt.Errorf("钻石不足,当前: %d,需要: %d", user.Diamonds, amount)
+ }
+ user.Diamonds -= int(amount)
+ case "redstone_coins":
+ if user.RedstoneCoins < amount {
+ return fmt.Errorf("红石币不足,当前: %d,需要: %d", user.RedstoneCoins, amount)
+ }
+ user.RedstoneCoins -= amount
+ case "gold_coins":
+ if user.GoldCoins < int(amount) {
+ return fmt.Errorf("金币不足,当前: %d,需要: %d", user.GoldCoins, amount)
+ }
+ user.GoldCoins -= int(amount)
+ default:
+ return fmt.Errorf("未知货币类型: %s", currency)
+ }
+ return nil
+}
+
+// BuyItem 购买商品 (货币扣款 + 库存追踪 + 交易记录)
+func (s *UserStore) BuyItem(userID, itemID, itemName, currency string, amount int64, quantity int) (map[string]interface{}, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var user *User
+ for _, u := range s.users { if u.ID == userID { user = u; break } }
+ if user == nil { return nil, errors.New("用户不存在") }
+ switch currency {
+ case "diamonds": if user.Diamonds < int(amount) { return nil, fmt.Errorf("钻石不足") }; user.Diamonds -= int(amount)
+ case "redstone_coins": if user.RedstoneCoins < amount { return nil, fmt.Errorf("红石币不足") }; user.RedstoneCoins -= amount
+ case "gold_coins": if user.GoldCoins < int(amount) { return nil, fmt.Errorf("金币不足") }; user.GoldCoins -= int(amount)
+ default: return nil, fmt.Errorf("未知货币")
+ }
+ if user.Inventory == nil { user.Inventory = make(map[string]int) }
+ user.Inventory[itemID] += quantity
+ if itemID == "speed_boost" { user.SpeedBoostUntil = time.Now().Unix() + 3600 }
+ rec := PurchaseRecord{ItemID: itemID, ItemName: itemName, Currency: currency, Amount: amount, Timestamp: time.Now().Unix()}
+ user.PurchaseHistory = append([]PurchaseRecord{rec}, user.PurchaseHistory...)
+ if len(user.PurchaseHistory) > 50 { user.PurchaseHistory = user.PurchaseHistory[:50] }
+ return map[string]interface{}{"message": "购买成功", "item_id": itemID, "quantity": quantity, "user": userToMap(user)}, nil
+}
+
+// UseItem 使用道具
+func (s *UserStore) UseItem(userID, itemID string) (map[string]interface{}, error) {
+ s.mu.Lock(); defer s.mu.Unlock()
+ var user *User
+ for _, u := range s.users { if u.ID == userID { user = u; break } }
+ if user == nil { return nil, errors.New("用户不存在") }
+ if user.Inventory == nil || user.Inventory[itemID] <= 0 { return nil, errors.New("道具数量不足") }
+ user.Inventory[itemID]--
+ msg := "使用成功"
+ switch itemID {
+ case "speed_boost":
+ user.SpeedBoostUntil = time.Now().Unix() + 3600
+ user.EquippedItem = itemID
+ msg = "速度加成已激活(1小时)"
+ case "exp_boost":
+ user.Exp += 500
+ if user.Exp >= user.Level*1000 {
+ user.Level++
+ user.Exp -= (user.Level - 1) * 1000
+ msg = fmt.Sprintf("升级! Lv.%d", user.Level)
+ } else {
+ msg = "获得500经验值"
+ }
+ case "diamond_pack":
+ user.Diamonds += 10
+ msg = "获得10钻石"
+ case "gold_pack":
+ user.GoldCoins += 5
+ msg = "获得5金币"
+ case "rs_pack":
+ user.RedstoneCoins += 10000
+ msg = "获得10000红石币"
+ }
+ return map[string]interface{}{"message": msg, "user": userToMap(user)}, nil
+}
+
+// userToMap 将 User 转换为返回给客户端的 map
+func userToMap(u *User) map[string]interface{} {
+ inv := u.Inventory
+ if inv == nil { inv = make(map[string]int) }
+ hist := u.PurchaseHistory
+ if hist == nil { hist = []PurchaseRecord{} }
+ return map[string]interface{}{
+ "id": u.ID,
+ "username": u.Username,
+ "level": u.Level,
+ "exp": u.Exp,
+ "diamonds": u.Diamonds,
+ "redstone_coins": u.RedstoneCoins,
+ "gold_coins": u.GoldCoins,
+ "last_login_date": u.LastLoginDate,
+ "created_at": u.CreatedAt,
+ "inventory": inv,
+ "equipped_item": u.EquippedItem,
+ "speed_boost_until": u.SpeedBoostUntil,
+ "purchase_history": hist,
+ }
+}
+
+// ==================== JWT ====================
+
+type JWTManager struct {
+ secretKey []byte
+ expires time.Duration
+}
+
+func NewJWTManager(secret string) *JWTManager {
+ return &JWTManager{
+ secretKey: []byte(secret),
+ expires: 24 * time.Hour,
+ }
+}
+
+type Claims struct {
+ UserID string `json:"uid"`
+ Username string `json:"username"`
+ jwt.RegisteredClaims
+}
+
+func (m *JWTManager) Generate(user *User) (string, error) {
+ claims := &Claims{
+ UserID: user.ID,
+ Username: user.Username,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.expires)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ },
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString(m.secretKey)
+}
+
+func (m *JWTManager) Verify(tokenStr string) (*Claims, error) {
+ token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return m.secretKey, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ claims, ok := token.Claims.(*Claims)
+ if !ok || !token.Valid {
+ return nil, errors.New("invalid token")
+ }
+ return claims, nil
+}
diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go
new file mode 100644
index 0000000..8751a09
--- /dev/null
+++ b/server/internal/auth/handler.go
@@ -0,0 +1,225 @@
+package auth
+
+import (
+ "net/http"
+ "strings"
+
+ "mrcc/pkg/response"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Handler struct {
+ store *UserStore
+ jwt *JWTManager
+}
+
+func NewHandler(store *UserStore, jwtMgr *JWTManager) *Handler {
+ return &Handler{store: store, jwt: jwtMgr}
+}
+
+// RegisterAuthRoutes 注册认证路由 (/api/auth)
+func (h *Handler) RegisterAuthRoutes(r *gin.RouterGroup) {
+ r.POST("/register", h.Register)
+ r.POST("/login", h.Login)
+ r.GET("/me", h.AuthMiddleware(), h.Me)
+}
+
+// RegisterEconomyRoutes 注册经济路由 (/api)
+func (h *Handler) RegisterEconomyRoutes(r *gin.RouterGroup) {
+ r.POST("/daily-reward", h.AuthMiddleware(), h.DailyReward)
+ r.POST("/spend", h.AuthMiddleware(), h.Spend)
+ r.POST("/buy", h.AuthMiddleware(), h.Buy)
+ r.POST("/use-item", h.AuthMiddleware(), h.UseItem)
+}
+
+// RegisterRoutes 注册全部路由(兼容旧调用)
+func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
+ h.RegisterAuthRoutes(r)
+ h.RegisterEconomyRoutes(r)
+}
+
+type registerReq struct {
+ Username string `json:"username" binding:"required,min=3,max=20"`
+ Password string `json:"password" binding:"required,min=6,max=50"`
+}
+
+// Register 用户注册
+func (h *Handler) Register(c *gin.Context) {
+ var req registerReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.Error(c, http.StatusBadRequest, "参数无效: "+err.Error())
+ return
+ }
+
+ user, err := h.store.Create(req.Username, req.Password)
+ if err != nil {
+ response.Error(c, http.StatusConflict, err.Error())
+ return
+ }
+
+ token, err := h.jwt.Generate(user)
+ if err != nil {
+ response.Error(c, http.StatusInternalServerError, "生成令牌失败")
+ return
+ }
+
+ response.OK(c, gin.H{
+ "token": token,
+ "user": userToMap(user),
+ })
+}
+
+// Login 用户登录
+func (h *Handler) Login(c *gin.Context) {
+ var req registerReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.Error(c, http.StatusBadRequest, "参数无效: "+err.Error())
+ return
+ }
+
+ user, err := h.store.VerifyPassword(req.Username, req.Password)
+ if err != nil {
+ response.Error(c, http.StatusUnauthorized, err.Error())
+ return
+ }
+
+ token, err := h.jwt.Generate(user)
+ if err != nil {
+ response.Error(c, http.StatusInternalServerError, "生成令牌失败")
+ return
+ }
+
+ response.OK(c, gin.H{
+ "token": token,
+ "user": userToMap(user),
+ })
+}
+
+// Me 获取当前用户完整信息
+func (h *Handler) Me(c *gin.Context) {
+ claims, exists := c.Get("claims")
+ if !exists {
+ response.Error(c, http.StatusUnauthorized, "未认证")
+ return
+ }
+ cl := claims.(*Claims)
+ user, ok := h.store.GetByID(cl.UserID)
+ if !ok {
+ response.Error(c, http.StatusNotFound, "用户不存在")
+ return
+ }
+ response.OK(c, userToMap(user))
+}
+
+// DailyReward 每日登录奖励
+func (h *Handler) DailyReward(c *gin.Context) {
+ claims, exists := c.Get("claims")
+ if !exists {
+ response.Error(c, http.StatusUnauthorized, "未认证")
+ return
+ }
+ cl := claims.(*Claims)
+
+ result, err := h.store.DailyLogin(cl.UserID)
+ if err != nil {
+ response.Error(c, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ response.OK(c, result)
+}
+
+type spendReq struct {
+ Currency string `json:"currency" binding:"required"`
+ Amount int64 `json:"amount" binding:"required,min=1"`
+}
+
+// Spend 消费货币
+func (h *Handler) Spend(c *gin.Context) {
+ claims, exists := c.Get("claims")
+ if !exists {
+ response.Error(c, http.StatusUnauthorized, "未认证")
+ return
+ }
+ cl := claims.(*Claims)
+
+ var req spendReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.Error(c, http.StatusBadRequest, "参数无效: "+err.Error())
+ return
+ }
+
+ if err := h.store.SpendCurrency(cl.UserID, req.Currency, req.Amount); err != nil {
+ response.Error(c, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ // 返回最新余额
+ user, _ := h.store.GetByID(cl.UserID)
+ response.OK(c, gin.H{
+ "message": "消费成功",
+ "currency": req.Currency,
+ "amount": req.Amount,
+ "user": userToMap(user),
+ })
+}
+
+type buyReq struct {
+ ItemID string `json:"item_id" binding:"required"`
+ ItemName string `json:"item_name" binding:"required"`
+ Currency string `json:"currency" binding:"required"`
+ Amount int64 `json:"amount" binding:"required,min=1"`
+ Quantity int `json:"quantity"`
+}
+
+// Buy 购买商品 (含库存追踪)
+func (h *Handler) Buy(c *gin.Context) {
+ claims, _ := c.Get("claims"); cl := claims.(*Claims)
+ var req buyReq
+ if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, err.Error()); return }
+ if req.Quantity <= 0 { req.Quantity = 1 }
+ result, err := h.store.BuyItem(cl.UserID, req.ItemID, req.ItemName, req.Currency, req.Amount, req.Quantity)
+ if err != nil { response.Error(c, http.StatusBadRequest, err.Error()); return }
+ response.OK(c, result)
+}
+
+type useReq struct {
+ ItemID string `json:"item_id" binding:"required"`
+}
+
+// UseItem 使用道具
+func (h *Handler) UseItem(c *gin.Context) {
+ claims, _ := c.Get("claims"); cl := claims.(*Claims)
+ var req useReq
+ if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, err.Error()); return }
+ result, err := h.store.UseItem(cl.UserID, req.ItemID)
+ if err != nil { response.Error(c, http.StatusBadRequest, err.Error()); return }
+ response.OK(c, result)
+}
+
+// AuthMiddleware JWT 认证中间件
+func (h *Handler) AuthMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ auth := c.GetHeader("Authorization")
+ if auth == "" {
+ response.Error(c, http.StatusUnauthorized, "缺少认证令牌")
+ c.Abort()
+ return
+ }
+ parts := strings.SplitN(auth, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ response.Error(c, http.StatusUnauthorized, "认证格式错误")
+ c.Abort()
+ return
+ }
+ claims, err := h.jwt.Verify(parts[1])
+ if err != nil {
+ response.Error(c, http.StatusUnauthorized, "令牌无效或已过期")
+ c.Abort()
+ return
+ }
+ c.Set("claims", claims)
+ c.Next()
+ }
+}