From 3b4429bdd906968295481f3e31a91465889a605d Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Sun, 1 Mar 2026 18:51:12 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?API=E6=8E=A5=E5=8F=A3=E3=80=81RPC=E6=9C=8D=E5=8A=A1=E3=80=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=A4=84=E7=90=86=E5=92=8C=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=B1=BB=E5=9E=8B=E5=8F=8A=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A0=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .serena/.gitignore | 1 + .serena/project.yml | 126 +++++++++++++ apps/api/desc/auth.api | 19 ++ .../handler/auth/deviceLoginHandler.go | 28 +++ apps/api/internal/handler/routes.go | 15 ++ .../internal/logic/auth/deviceLoginLogic.go | 87 +++++++++ apps/api/internal/svc/serviceContext.go | 7 +- apps/api/internal/types/types.go | 8 + apps/api/ppanel.go | 1 - apps/rpc/core/core.proto | 20 ++ apps/rpc/core/core/core.pb.go | 177 +++++++++++++++++- apps/rpc/core/core/core_grpc.pb.go | 40 ++++ apps/rpc/core/coreClient/core.go | 24 ++- .../core/internal/logic/deviceLoginLogic.go | 104 ++++++++++ apps/rpc/core/internal/repo/device_repo.go | 155 +++++++++++++++ apps/rpc/core/internal/server/coreServer.go | 6 + apps/rpc/core/internal/svc/serviceContext.go | 20 +- pkg/jwtx/jwt.go | 7 +- pkg/xerr/errcode.go | 2 + pkg/xerr/errmsg.go | 2 + 20 files changed, 824 insertions(+), 25 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 apps/api/internal/handler/auth/deviceLoginHandler.go create mode 100644 apps/api/internal/logic/auth/deviceLoginLogic.go create mode 100644 apps/rpc/core/internal/logic/deviceLoginLogic.go create mode 100644 apps/rpc/core/internal/repo/device_repo.go diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..87612aa --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,126 @@ +# the name by which the project can be referenced within Serena +project_name: "zero-ppanel" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- go + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: diff --git a/apps/api/desc/auth.api b/apps/api/desc/auth.api index fb3e7fc..a118510 100644 --- a/apps/api/desc/auth.api +++ b/apps/api/desc/auth.api @@ -19,6 +19,14 @@ type ( Token string `json:"token"` } + DeviceLoginReq { + Identifier string `json:"identifier" validate:"required"` + UserAgent string `json:"user_agent" validate:"required"` + ShortCode string `json:"short_code,optional"` + CfToken string `json:"cf_token,optional"` + IP string `header:"X-Original-Forwarded-For,optional"` + } + UserRegisterReq { Identifier string `json:"identifier"` Email string `json:"email" validate:"required"` @@ -58,3 +66,14 @@ service ppanel-api { post /reset (ResetPasswordReq) returns (LoginResp) } + +@server ( + prefix: /api/v1/auth + group: auth + middleware: DecryptMiddleware +) +service ppanel-api { + @doc "设备登录" + @handler DeviceLoginHandler + post /login/device (DeviceLoginReq) returns (LoginResp) +} diff --git a/apps/api/internal/handler/auth/deviceLoginHandler.go b/apps/api/internal/handler/auth/deviceLoginHandler.go new file mode 100644 index 0000000..8762d46 --- /dev/null +++ b/apps/api/internal/handler/auth/deviceLoginHandler.go @@ -0,0 +1,28 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.2 + +package auth + +import ( + "net/http" + + "github.com/zero-ppanel/zero-ppanel/apps/api/internal/logic/auth" + "github.com/zero-ppanel/zero-ppanel/apps/api/internal/svc" + "github.com/zero-ppanel/zero-ppanel/apps/api/internal/types" + "github.com/zero-ppanel/zero-ppanel/pkg/result" +) + +// 设备登录 +func DeviceLoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.DeviceLoginReq + if err := result.Parse(r, &req); err != nil { + result.HttpResult(r, w, nil, err) + return + } + + l := auth.NewDeviceLoginLogic(r.Context(), svcCtx) + resp, err := l.DeviceLogin(&req) + result.HttpResult(r, w, resp, err) + } +} diff --git a/apps/api/internal/handler/routes.go b/apps/api/internal/handler/routes.go index abe2fca..c454cc3 100644 --- a/apps/api/internal/handler/routes.go +++ b/apps/api/internal/handler/routes.go @@ -38,6 +38,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/auth"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.DecryptMiddleware}, + []rest.Route{ + { + // 设备登录 + Method: http.MethodPost, + Path: "/login/device", + Handler: auth.DeviceLoginHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/auth"), + ) + server.AddRoutes( []rest.Route{ { diff --git a/apps/api/internal/logic/auth/deviceLoginLogic.go b/apps/api/internal/logic/auth/deviceLoginLogic.go new file mode 100644 index 0000000..7658e24 --- /dev/null +++ b/apps/api/internal/logic/auth/deviceLoginLogic.go @@ -0,0 +1,87 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.9.2 + +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/zero-ppanel/zero-ppanel/apps/api/internal/svc" + "github.com/zero-ppanel/zero-ppanel/apps/api/internal/types" + "github.com/zero-ppanel/zero-ppanel/apps/rpc/core/core" + "github.com/zero-ppanel/zero-ppanel/pkg/jwtx" + "github.com/zero-ppanel/zero-ppanel/pkg/xerr" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeviceLoginLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// 设备登录 +func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeviceLoginLogic { + return &DeviceLoginLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginReq) (resp *types.LoginResp, err error) { + // 1. 调用 Core RPC 设备登录 + rpcResp, err := l.svcCtx.CoreRpc.DeviceLogin(l.ctx, &core.DeviceLoginReq{ + Identifier: req.Identifier, + UserAgent: req.UserAgent, + Ip: req.IP, + ShortCode: req.ShortCode, + }) + if err != nil { + return nil, err + } + + // 2. 检查用户是否禁用 + if rpcResp.IsDisabled { + return nil, xerr.NewErrCode(xerr.UserDisabled) + } + + // 3. 生成 session ID + sessionId := uuid.New().String() + + // 4. 生成 JWT Token + token, err := jwtx.GenerateToken( + l.svcCtx.Config.JwtAuth.AccessSecret, + rpcResp.UserId, + "device", + rpcResp.IsAdmin, + l.svcCtx.Config.JwtAuth.AccessExpire, + ) + if err != nil { + l.Errorf("GenerateToken error: %v", err) + return nil, xerr.NewErrCode(xerr.ServerError) + } + + // 5. 写入 Redis session + expireDuration := time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire) * time.Second + + // session:{sessionId} → userId + sessionKey := fmt.Sprintf("session:%s", sessionId) + if err := l.svcCtx.Redis.SetexCtx(l.ctx, sessionKey, fmt.Sprintf("%d", rpcResp.UserId), int(expireDuration.Seconds())); err != nil { + l.Errorf("Redis set session error: %v", err) + } + + // device:{identifier} → sessionId + deviceKey := fmt.Sprintf("device:%s", req.Identifier) + if err := l.svcCtx.Redis.SetexCtx(l.ctx, deviceKey, sessionId, int(expireDuration.Seconds())); err != nil { + l.Errorf("Redis set device session error: %v", err) + } + + return &types.LoginResp{ + Token: token, + }, nil +} diff --git a/apps/api/internal/svc/serviceContext.go b/apps/api/internal/svc/serviceContext.go index cfd53d1..08d6deb 100644 --- a/apps/api/internal/svc/serviceContext.go +++ b/apps/api/internal/svc/serviceContext.go @@ -9,14 +9,16 @@ import ( coreClient "github.com/zero-ppanel/zero-ppanel/apps/rpc/core/coreClient" "github.com/zero-ppanel/zero-ppanel/pkg/signature" "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/rest" "github.com/zeromicro/go-zero/zrpc" ) type ServiceContext struct { Config config.Config CoreRpc coreClient.Core + Redis *redis.Redis SignatureMiddleware *middleware.SignatureMiddleware - DecryptMiddleware *middleware.DecryptMiddleware + DecryptMiddleware rest.Middleware } func NewServiceContext(c config.Config) *ServiceContext { @@ -26,7 +28,8 @@ func NewServiceContext(c config.Config) *ServiceContext { return &ServiceContext{ Config: c, CoreRpc: coreClient.NewCore(zrpc.MustNewClient(c.CoreRpc)), + Redis: rds, SignatureMiddleware: middleware.NewSignatureMiddleware(c, nonceStore), - DecryptMiddleware: middleware.NewDecryptMiddleware(c), + DecryptMiddleware: middleware.NewDecryptMiddleware(c).Handle, } } diff --git a/apps/api/internal/types/types.go b/apps/api/internal/types/types.go index c4ecb05..af54936 100644 --- a/apps/api/internal/types/types.go +++ b/apps/api/internal/types/types.go @@ -31,6 +31,14 @@ type CreateTicketReq struct { Content string `json:"content"` } +type DeviceLoginReq struct { + Identifier string `json:"identifier" validate:"required"` + UserAgent string `json:"user_agent" validate:"required"` + ShortCode string `json:"short_code,optional"` + CfToken string `json:"cf_token,optional"` + IP string `header:"X-Original-Forwarded-For,optional"` +} + type DocumentDetailReq struct { Id int64 `path:"id"` } diff --git a/apps/api/ppanel.go b/apps/api/ppanel.go index e7f8a0d..e06fca1 100644 --- a/apps/api/ppanel.go +++ b/apps/api/ppanel.go @@ -30,7 +30,6 @@ func main() { ctx := svc.NewServiceContext(c) server.Use(ctx.SignatureMiddleware.Handle) - server.Use(ctx.DecryptMiddleware.Handle) handler.RegisterHandlers(server, ctx) // Registe global http error handler diff --git a/apps/rpc/core/core.proto b/apps/rpc/core/core.proto index c3e3b72..97c3552 100644 --- a/apps/rpc/core/core.proto +++ b/apps/rpc/core/core.proto @@ -45,6 +45,23 @@ message GetNodeInfoResp { string status = 4; } +// ---------------------------------------------------------------------------- +// Device 服务定义 +// ---------------------------------------------------------------------------- +message DeviceLoginReq { + string identifier = 1; + string user_agent = 2; + string ip = 3; + string short_code = 4; +} + +message DeviceLoginResp { + int64 user_id = 1; + bool is_admin = 2; + bool is_disabled = 3; + bool is_new_user = 4; +} + // ---------------------------------------------------------------------------- // 核心 RPC 服务接口 // ---------------------------------------------------------------------------- @@ -57,4 +74,7 @@ service Core { // 节点相关 rpc GetNodeInfo(GetNodeInfoReq) returns(GetNodeInfoResp); + + // 设备登录 + rpc DeviceLogin(DeviceLoginReq) returns(DeviceLoginResp); } diff --git a/apps/rpc/core/core/core.pb.go b/apps/rpc/core/core/core.pb.go index 3fc7c61..9645cf1 100644 --- a/apps/rpc/core/core/core.pb.go +++ b/apps/rpc/core/core/core.pb.go @@ -374,6 +374,145 @@ func (x *GetNodeInfoResp) GetStatus() string { return "" } +// ---------------------------------------------------------------------------- +// Device 服务定义 +// ---------------------------------------------------------------------------- +type DeviceLoginReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identifier string `protobuf:"bytes,1,opt,name=identifier,proto3" json:"identifier,omitempty"` + UserAgent string `protobuf:"bytes,2,opt,name=user_agent,json=userAgent,proto3" json:"user_agent,omitempty"` + Ip string `protobuf:"bytes,3,opt,name=ip,proto3" json:"ip,omitempty"` + ShortCode string `protobuf:"bytes,4,opt,name=short_code,json=shortCode,proto3" json:"short_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeviceLoginReq) Reset() { + *x = DeviceLoginReq{} + mi := &file_core_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeviceLoginReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeviceLoginReq) ProtoMessage() {} + +func (x *DeviceLoginReq) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeviceLoginReq.ProtoReflect.Descriptor instead. +func (*DeviceLoginReq) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{6} +} + +func (x *DeviceLoginReq) GetIdentifier() string { + if x != nil { + return x.Identifier + } + return "" +} + +func (x *DeviceLoginReq) GetUserAgent() string { + if x != nil { + return x.UserAgent + } + return "" +} + +func (x *DeviceLoginReq) GetIp() string { + if x != nil { + return x.Ip + } + return "" +} + +func (x *DeviceLoginReq) GetShortCode() string { + if x != nil { + return x.ShortCode + } + return "" +} + +type DeviceLoginResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + IsAdmin bool `protobuf:"varint,2,opt,name=is_admin,json=isAdmin,proto3" json:"is_admin,omitempty"` + IsDisabled bool `protobuf:"varint,3,opt,name=is_disabled,json=isDisabled,proto3" json:"is_disabled,omitempty"` + IsNewUser bool `protobuf:"varint,4,opt,name=is_new_user,json=isNewUser,proto3" json:"is_new_user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeviceLoginResp) Reset() { + *x = DeviceLoginResp{} + mi := &file_core_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeviceLoginResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeviceLoginResp) ProtoMessage() {} + +func (x *DeviceLoginResp) ProtoReflect() protoreflect.Message { + mi := &file_core_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeviceLoginResp.ProtoReflect.Descriptor instead. +func (*DeviceLoginResp) Descriptor() ([]byte, []int) { + return file_core_proto_rawDescGZIP(), []int{7} +} + +func (x *DeviceLoginResp) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *DeviceLoginResp) GetIsAdmin() bool { + if x != nil { + return x.IsAdmin + } + return false +} + +func (x *DeviceLoginResp) GetIsDisabled() bool { + if x != nil { + return x.IsDisabled + } + return false +} + +func (x *DeviceLoginResp) GetIsNewUser() bool { + if x != nil { + return x.IsNewUser + } + return false +} + var File_core_proto protoreflect.FileDescriptor const file_core_proto_rawDesc = "" + @@ -403,11 +542,27 @@ const file_core_proto_rawDesc = "" + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + "\x06server\x18\x03 \x01(\tR\x06server\x12\x16\n" + - "\x06status\x18\x04 \x01(\tR\x06status2\xa8\x01\n" + + "\x06status\x18\x04 \x01(\tR\x06status\"~\n" + + "\x0eDeviceLoginReq\x12\x1e\n" + + "\n" + + "identifier\x18\x01 \x01(\tR\n" + + "identifier\x12\x1d\n" + + "\n" + + "user_agent\x18\x02 \x01(\tR\tuserAgent\x12\x0e\n" + + "\x02ip\x18\x03 \x01(\tR\x02ip\x12\x1d\n" + + "\n" + + "short_code\x18\x04 \x01(\tR\tshortCode\"\x86\x01\n" + + "\x0fDeviceLoginResp\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x19\n" + + "\bis_admin\x18\x02 \x01(\bR\aisAdmin\x12\x1f\n" + + "\vis_disabled\x18\x03 \x01(\bR\n" + + "isDisabled\x12\x1e\n" + + "\vis_new_user\x18\x04 \x01(\bR\tisNewUser2\xe4\x01\n" + "\x04Core\x12(\n" + "\x04Ping\x12\v.core.Empty\x1a\x13.core.BasicResponse\x12:\n" + "\vGetUserInfo\x12\x14.core.GetUserInfoReq\x1a\x15.core.GetUserInfoResp\x12:\n" + - "\vGetNodeInfo\x12\x14.core.GetNodeInfoReq\x1a\x15.core.GetNodeInfoRespB\bZ\x06./coreb\x06proto3" + "\vGetNodeInfo\x12\x14.core.GetNodeInfoReq\x1a\x15.core.GetNodeInfoResp\x12:\n" + + "\vDeviceLogin\x12\x14.core.DeviceLoginReq\x1a\x15.core.DeviceLoginRespB\bZ\x06./coreb\x06proto3" var ( file_core_proto_rawDescOnce sync.Once @@ -421,7 +576,7 @@ func file_core_proto_rawDescGZIP() []byte { return file_core_proto_rawDescData } -var file_core_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_core_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_core_proto_goTypes = []any{ (*Empty)(nil), // 0: core.Empty (*BasicResponse)(nil), // 1: core.BasicResponse @@ -429,16 +584,20 @@ var file_core_proto_goTypes = []any{ (*GetUserInfoResp)(nil), // 3: core.GetUserInfoResp (*GetNodeInfoReq)(nil), // 4: core.GetNodeInfoReq (*GetNodeInfoResp)(nil), // 5: core.GetNodeInfoResp + (*DeviceLoginReq)(nil), // 6: core.DeviceLoginReq + (*DeviceLoginResp)(nil), // 7: core.DeviceLoginResp } var file_core_proto_depIdxs = []int32{ 0, // 0: core.Core.Ping:input_type -> core.Empty 2, // 1: core.Core.GetUserInfo:input_type -> core.GetUserInfoReq 4, // 2: core.Core.GetNodeInfo:input_type -> core.GetNodeInfoReq - 1, // 3: core.Core.Ping:output_type -> core.BasicResponse - 3, // 4: core.Core.GetUserInfo:output_type -> core.GetUserInfoResp - 5, // 5: core.Core.GetNodeInfo:output_type -> core.GetNodeInfoResp - 3, // [3:6] is the sub-list for method output_type - 0, // [0:3] is the sub-list for method input_type + 6, // 3: core.Core.DeviceLogin:input_type -> core.DeviceLoginReq + 1, // 4: core.Core.Ping:output_type -> core.BasicResponse + 3, // 5: core.Core.GetUserInfo:output_type -> core.GetUserInfoResp + 5, // 6: core.Core.GetNodeInfo:output_type -> core.GetNodeInfoResp + 7, // 7: core.Core.DeviceLogin:output_type -> core.DeviceLoginResp + 4, // [4:8] is the sub-list for method output_type + 0, // [0:4] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -455,7 +614,7 @@ func file_core_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_core_proto_rawDesc), len(file_core_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 8, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/rpc/core/core/core_grpc.pb.go b/apps/rpc/core/core/core_grpc.pb.go index 967db4c..bd99aa7 100644 --- a/apps/rpc/core/core/core_grpc.pb.go +++ b/apps/rpc/core/core/core_grpc.pb.go @@ -22,6 +22,7 @@ const ( Core_Ping_FullMethodName = "/core.Core/Ping" Core_GetUserInfo_FullMethodName = "/core.Core/GetUserInfo" Core_GetNodeInfo_FullMethodName = "/core.Core/GetNodeInfo" + Core_DeviceLogin_FullMethodName = "/core.Core/DeviceLogin" ) // CoreClient is the client API for Core service. @@ -38,6 +39,8 @@ type CoreClient interface { GetUserInfo(ctx context.Context, in *GetUserInfoReq, opts ...grpc.CallOption) (*GetUserInfoResp, error) // 节点相关 GetNodeInfo(ctx context.Context, in *GetNodeInfoReq, opts ...grpc.CallOption) (*GetNodeInfoResp, error) + // 设备登录 + DeviceLogin(ctx context.Context, in *DeviceLoginReq, opts ...grpc.CallOption) (*DeviceLoginResp, error) } type coreClient struct { @@ -78,6 +81,16 @@ func (c *coreClient) GetNodeInfo(ctx context.Context, in *GetNodeInfoReq, opts . return out, nil } +func (c *coreClient) DeviceLogin(ctx context.Context, in *DeviceLoginReq, opts ...grpc.CallOption) (*DeviceLoginResp, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeviceLoginResp) + err := c.cc.Invoke(ctx, Core_DeviceLogin_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // CoreServer is the server API for Core service. // All implementations must embed UnimplementedCoreServer // for forward compatibility. @@ -92,6 +105,8 @@ type CoreServer interface { GetUserInfo(context.Context, *GetUserInfoReq) (*GetUserInfoResp, error) // 节点相关 GetNodeInfo(context.Context, *GetNodeInfoReq) (*GetNodeInfoResp, error) + // 设备登录 + DeviceLogin(context.Context, *DeviceLoginReq) (*DeviceLoginResp, error) mustEmbedUnimplementedCoreServer() } @@ -111,6 +126,9 @@ func (UnimplementedCoreServer) GetUserInfo(context.Context, *GetUserInfoReq) (*G func (UnimplementedCoreServer) GetNodeInfo(context.Context, *GetNodeInfoReq) (*GetNodeInfoResp, error) { return nil, status.Errorf(codes.Unimplemented, "method GetNodeInfo not implemented") } +func (UnimplementedCoreServer) DeviceLogin(context.Context, *DeviceLoginReq) (*DeviceLoginResp, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeviceLogin not implemented") +} func (UnimplementedCoreServer) mustEmbedUnimplementedCoreServer() {} func (UnimplementedCoreServer) testEmbeddedByValue() {} @@ -186,6 +204,24 @@ func _Core_GetNodeInfo_Handler(srv interface{}, ctx context.Context, dec func(in return interceptor(ctx, in, info, handler) } +func _Core_DeviceLogin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeviceLoginReq) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CoreServer).DeviceLogin(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Core_DeviceLogin_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CoreServer).DeviceLogin(ctx, req.(*DeviceLoginReq)) + } + return interceptor(ctx, in, info, handler) +} + // Core_ServiceDesc is the grpc.ServiceDesc for Core service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -205,6 +241,10 @@ var Core_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetNodeInfo", Handler: _Core_GetNodeInfo_Handler, }, + { + MethodName: "DeviceLogin", + Handler: _Core_DeviceLogin_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "core.proto", diff --git a/apps/rpc/core/coreClient/core.go b/apps/rpc/core/coreClient/core.go index bbb86f8..e392bb5 100644 --- a/apps/rpc/core/coreClient/core.go +++ b/apps/rpc/core/coreClient/core.go @@ -2,7 +2,7 @@ // goctl 1.9.2 // Source: core.proto -package coreclient +package coreClient import ( "context" @@ -14,12 +14,14 @@ import ( ) type ( - BasicResponse = core.BasicResponse - Empty = core.Empty - GetNodeInfoReq = core.GetNodeInfoReq - GetNodeInfoResp = core.GetNodeInfoResp - GetUserInfoReq = core.GetUserInfoReq - GetUserInfoResp = core.GetUserInfoResp + BasicResponse = core.BasicResponse + DeviceLoginReq = core.DeviceLoginReq + DeviceLoginResp = core.DeviceLoginResp + Empty = core.Empty + GetNodeInfoReq = core.GetNodeInfoReq + GetNodeInfoResp = core.GetNodeInfoResp + GetUserInfoReq = core.GetUserInfoReq + GetUserInfoResp = core.GetUserInfoResp Core interface { // Ping 检查服务健康状态 @@ -28,6 +30,8 @@ type ( GetUserInfo(ctx context.Context, in *GetUserInfoReq, opts ...grpc.CallOption) (*GetUserInfoResp, error) // 节点相关 GetNodeInfo(ctx context.Context, in *GetNodeInfoReq, opts ...grpc.CallOption) (*GetNodeInfoResp, error) + // 设备登录 + DeviceLogin(ctx context.Context, in *DeviceLoginReq, opts ...grpc.CallOption) (*DeviceLoginResp, error) } defaultCore struct { @@ -58,3 +62,9 @@ func (m *defaultCore) GetNodeInfo(ctx context.Context, in *GetNodeInfoReq, opts client := core.NewCoreClient(m.cli.Conn()) return client.GetNodeInfo(ctx, in, opts...) } + +// 设备登录 +func (m *defaultCore) DeviceLogin(ctx context.Context, in *DeviceLoginReq, opts ...grpc.CallOption) (*DeviceLoginResp, error) { + client := core.NewCoreClient(m.cli.Conn()) + return client.DeviceLogin(ctx, in, opts...) +} diff --git a/apps/rpc/core/internal/logic/deviceLoginLogic.go b/apps/rpc/core/internal/logic/deviceLoginLogic.go new file mode 100644 index 0000000..012f72a --- /dev/null +++ b/apps/rpc/core/internal/logic/deviceLoginLogic.go @@ -0,0 +1,104 @@ +package logic + +import ( + "context" + "database/sql" + "errors" + + "github.com/zero-ppanel/zero-ppanel/apps/rpc/core/core" + "github.com/zero-ppanel/zero-ppanel/apps/rpc/core/internal/repo" + "github.com/zero-ppanel/zero-ppanel/apps/rpc/core/internal/svc" + "github.com/zero-ppanel/zero-ppanel/pkg/xerr" + + "github.com/zeromicro/go-zero/core/logx" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type DeviceLoginLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeviceLoginLogic { + return &DeviceLoginLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +// DeviceLogin 设备登录:查设备 → 不存在则注册 → 返回用户信息 +func (l *DeviceLoginLogic) DeviceLogin(in *core.DeviceLoginReq) (*core.DeviceLoginResp, error) { + // 1. 查询设备 + device, err := l.svcCtx.DeviceRepo.FindDeviceByIdentifier(l.ctx, in.Identifier) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + l.Errorf("FindDeviceByIdentifier error: %v", err) + return nil, status.Error(codes.Code(xerr.DatabaseQueryError), "查询设备失败") + } + + var userID int64 + var isNewUser bool + var isAdmin bool + + if errors.Is(err, sql.ErrNoRows) || device == nil { + // 2. 设备不存在,创建用户+设备 + userID, err = l.svcCtx.DeviceRepo.CreateUserWithDevice(l.ctx, repo.CreateUserWithDeviceParams{ + Identifier: in.Identifier, + UserAgent: in.UserAgent, + IP: in.Ip, + ShortCode: in.ShortCode, + }) + if err != nil { + l.Errorf("CreateUserWithDevice error: %v", err) + return nil, status.Error(codes.Code(xerr.DatabaseInsertError), "创建用户设备失败") + } + isNewUser = true + + // 记录注册日志 + _ = l.svcCtx.DeviceRepo.InsertLoginLog(l.ctx, repo.LoginLogParams{ + UserID: userID, + Method: "device", + IP: in.Ip, + UserAgent: in.UserAgent, + Success: true, + }) + } else { + // 3. 设备存在,检查是否启用 + if !device.Enabled { + return nil, status.Error(codes.Code(xerr.DeviceNotEnabled), "设备已禁用") + } + + userID = device.UserID + + // 查用户信息 + user, err := l.svcCtx.DeviceRepo.FindUserByID(l.ctx, userID) + if err != nil { + l.Errorf("FindUserByID error: %v", err) + return nil, status.Error(codes.Code(xerr.DatabaseQueryError), "查询用户失败") + } + + if !user.Enable { + return nil, status.Error(codes.Code(xerr.UserDisabled), "用户已禁用") + } + + isAdmin = user.IsAdmin + } + + // 4. 记录登录日志 + _ = l.svcCtx.DeviceRepo.InsertLoginLog(l.ctx, repo.LoginLogParams{ + UserID: userID, + Method: "device", + IP: in.Ip, + UserAgent: in.UserAgent, + Success: true, + }) + + return &core.DeviceLoginResp{ + UserId: userID, + IsAdmin: isAdmin, + IsDisabled: false, + IsNewUser: isNewUser, + }, nil +} diff --git a/apps/rpc/core/internal/repo/device_repo.go b/apps/rpc/core/internal/repo/device_repo.go new file mode 100644 index 0000000..12226c2 --- /dev/null +++ b/apps/rpc/core/internal/repo/device_repo.go @@ -0,0 +1,155 @@ +package repo + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/zero-ppanel/zero-ppanel/pkg/cryptox" + "github.com/zeromicro/go-zero/core/stores/sqlx" +) + +// DeviceInfo represents a row from user_device table. +type DeviceInfo struct { + ID int64 `db:"id"` + UserID int64 `db:"user_id"` + Identifier string `db:"Identifier"` + Enabled bool `db:"enabled"` +} + +// UserInfo represents minimal user fields needed for login. +type UserInfo struct { + ID int64 `db:"id"` + Enable bool `db:"enable"` + IsAdmin bool `db:"is_admin"` + IsDeleted bool `db:"is_del"` +} + +// CreateUserWithDeviceParams holds parameters for creating a user and device in one transaction. +type CreateUserWithDeviceParams struct { + Identifier string + UserAgent string + IP string + ShortCode string +} + +// LoginLogParams holds parameters for inserting a login log. +type LoginLogParams struct { + UserID int64 + Method string + IP string + UserAgent string + Success bool +} + +// DeviceRepo defines device-related data access methods. +type DeviceRepo interface { + FindDeviceByIdentifier(ctx context.Context, identifier string) (*DeviceInfo, error) + FindUserByID(ctx context.Context, userID int64) (*UserInfo, error) + CreateUserWithDevice(ctx context.Context, params CreateUserWithDeviceParams) (int64, error) + InsertLoginLog(ctx context.Context, params LoginLogParams) error +} + +type deviceRepo struct { + conn sqlx.SqlConn +} + +func NewDeviceRepo(conn sqlx.SqlConn) DeviceRepo { + return &deviceRepo{conn: conn} +} + +func (r *deviceRepo) FindDeviceByIdentifier(ctx context.Context, identifier string) (*DeviceInfo, error) { + var d DeviceInfo + err := r.conn.QueryRowCtx(ctx, &d, + "SELECT `id`, `user_id`, `Identifier`, `enabled` FROM `user_device` WHERE `Identifier` = ? LIMIT 1", + identifier, + ) + if err != nil { + return nil, err + } + return &d, nil +} + +func (r *deviceRepo) FindUserByID(ctx context.Context, userID int64) (*UserInfo, error) { + var u UserInfo + err := r.conn.QueryRowCtx(ctx, &u, + "SELECT `id`, `enable`, `is_admin`, COALESCE(`is_del`, 0) AS `is_del` FROM `user` WHERE `id` = ? LIMIT 1", + userID, + ) + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *deviceRepo) CreateUserWithDevice(ctx context.Context, params CreateUserWithDeviceParams) (int64, error) { + // Generate a random password hash for device-only users + randomPwd, err := cryptox.GeneratePasswordHash("device-placeholder-" + params.Identifier) + if err != nil { + return 0, fmt.Errorf("hash password: %w", err) + } + + var userID int64 + err = r.conn.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error { + // 1. Create user + result, err := session.ExecCtx(ctx, + "INSERT INTO `user` (`password`, `algo`, `salt`, `enable`, `is_admin`, `created_at`, `updated_at`) VALUES (?, 'bcrypt', 'default', 1, 0, NOW(3), NOW(3))", + randomPwd, + ) + if err != nil { + return fmt.Errorf("insert user: %w", err) + } + userID, err = result.LastInsertId() + if err != nil { + return fmt.Errorf("last insert id: %w", err) + } + + // 2. Create auth method + _, err = session.ExecCtx(ctx, + "INSERT INTO `user_auth_methods` (`user_id`, `auth_type`, `auth_identifier`, `verified`, `created_at`, `updated_at`) VALUES (?, 'device', ?, 1, NOW(3), NOW(3))", + userID, params.Identifier, + ) + if err != nil { + return fmt.Errorf("insert auth method: %w", err) + } + + // 3. Create device + _, err = session.ExecCtx(ctx, + "INSERT INTO `user_device` (`user_id`, `ip`, `Identifier`, `short_code`, `user_agent`, `online`, `enabled`, `created_at`, `updated_at`) VALUES (?, ?, ?, ?, ?, 0, 1, NOW(3), NOW(3))", + userID, params.IP, params.Identifier, params.ShortCode, params.UserAgent, + ) + if err != nil { + return fmt.Errorf("insert device: %w", err) + } + + return nil + }) + return userID, err +} + +func (r *deviceRepo) InsertLoginLog(ctx context.Context, params LoginLogParams) error { + content, _ := json.Marshal(map[string]interface{}{ + "method": params.Method, + "login_ip": params.IP, + "user_agent": params.UserAgent, + "success": params.Success, + "timestamp": time.Now().UnixMilli(), + }) + + _, err := r.conn.ExecCtx(ctx, + "INSERT INTO `system_logs` (`type`, `date`, `object_id`, `content`, `created_at`) VALUES (?, ?, ?, ?, NOW(3))", + 6, // type=6 is Login log + time.Now().Format("2006-01-02"), + params.UserID, + string(content), + ) + if err != nil { + return fmt.Errorf("insert login log: %w", err) + } + return nil +} + +// ErrNotFound is re-exported for convenience. +var ErrNotFound = sql.ErrNoRows diff --git a/apps/rpc/core/internal/server/coreServer.go b/apps/rpc/core/internal/server/coreServer.go index 354144d..e14af0f 100644 --- a/apps/rpc/core/internal/server/coreServer.go +++ b/apps/rpc/core/internal/server/coreServer.go @@ -40,3 +40,9 @@ func (s *CoreServer) GetNodeInfo(ctx context.Context, in *core.GetNodeInfoReq) ( l := logic.NewGetNodeInfoLogic(ctx, s.svcCtx) return l.GetNodeInfo(in) } + +// 设备登录 +func (s *CoreServer) DeviceLogin(ctx context.Context, in *core.DeviceLoginReq) (*core.DeviceLoginResp, error) { + l := logic.NewDeviceLoginLogic(ctx, s.svcCtx) + return l.DeviceLogin(in) +} diff --git a/apps/rpc/core/internal/svc/serviceContext.go b/apps/rpc/core/internal/svc/serviceContext.go index 1a421b8..8e87356 100644 --- a/apps/rpc/core/internal/svc/serviceContext.go +++ b/apps/rpc/core/internal/svc/serviceContext.go @@ -1,13 +1,27 @@ package svc -import "github.com/zero-ppanel/zero-ppanel/apps/rpc/core/internal/config" +import ( + "github.com/zero-ppanel/zero-ppanel/apps/rpc/core/internal/config" + "github.com/zero-ppanel/zero-ppanel/apps/rpc/core/internal/repo" + "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/core/stores/sqlx" +) type ServiceContext struct { - Config config.Config + Config config.Config + DB sqlx.SqlConn + Redis *redis.Redis + DeviceRepo repo.DeviceRepo } func NewServiceContext(c config.Config) *ServiceContext { + db := sqlx.NewMysql(c.MySQL.DataSource) + rds := redis.MustNewRedis(c.CacheRedis) + return &ServiceContext{ - Config: c, + Config: c, + DB: db, + Redis: rds, + DeviceRepo: repo.NewDeviceRepo(db), } } diff --git a/pkg/jwtx/jwt.go b/pkg/jwtx/jwt.go index 2df41bd..9d27f57 100644 --- a/pkg/jwtx/jwt.go +++ b/pkg/jwtx/jwt.go @@ -13,10 +13,11 @@ type Claims struct { jwt.RegisteredClaims } -func GenerateToken(secret string, userId int64, isAdmin bool, expire int64) (string, error) { +func GenerateToken(secret string, userId int64, loginType string, isAdmin bool, expire int64) (string, error) { claims := Claims{ - UserId: userId, - IsAdmin: isAdmin, + UserId: userId, + LoginType: loginType, + IsAdmin: isAdmin, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expire) * time.Second)), IssuedAt: jwt.NewNumericDate(time.Now()), diff --git a/pkg/xerr/errcode.go b/pkg/xerr/errcode.go index 380f55d..f36f392 100644 --- a/pkg/xerr/errcode.go +++ b/pkg/xerr/errcode.go @@ -24,4 +24,6 @@ const ( SignatureInvalid = 7003 SignatureReplay = 7004 DecryptFailed = 7005 + DeviceLoginDisabled = 8001 + DeviceNotEnabled = 8002 ) diff --git a/pkg/xerr/errmsg.go b/pkg/xerr/errmsg.go index 7afc016..c678321 100644 --- a/pkg/xerr/errmsg.go +++ b/pkg/xerr/errmsg.go @@ -24,6 +24,8 @@ var codeText = map[int]string{ SignatureInvalid: "签名错误", SignatureReplay: "重放攻击", DecryptFailed: "解密失败", + DeviceLoginDisabled: "设备登录未启用", + DeviceNotEnabled: "设备已禁用", } func IsCodeErr(errcode uint32) bool {