feat: 新增设备登录功能,包括API接口、RPC服务、逻辑处理和相关数据类型及错误码。
All checks were successful
Build docker and publish / prepare (20.15.1) (push) Successful in 11s
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.admin image_name:ppanel-admin name:admin]) (push) Successful in 4m32s
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.api image_name:ppanel-api name:api]) (push) Successful in 8m6s
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.node image_name:ppanel-node name:node]) (push) Successful in 4m26s
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.queue image_name:ppanel-queue name:queue]) (push) Successful in 3m55s
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.rpc-core image_name:ppanel-rpc-core name:rpc-core]) (push) Successful in 8m23s
Build docker and publish / build (map[dockerfile:deploy/Dockerfile.scheduler image_name:ppanel-scheduler name:scheduler]) (push) Successful in 4m1s
Build docker and publish / deploy (push) Successful in 45s
Build docker and publish / notify (push) Successful in 3s

This commit is contained in:
shanshanzhong 2026-03-01 18:51:12 -08:00
parent be4cc669d2
commit 3b4429bdd9
20 changed files with 824 additions and 25 deletions

1
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/cache

126
.serena/project.yml Normal file
View File

@ -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:

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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{
{

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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"`
}

View File

@ -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

View File

@ -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);
}

View File

@ -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,
},

View File

@ -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",

View File

@ -2,7 +2,7 @@
// goctl 1.9.2
// Source: core.proto
package coreclient
package coreClient
import (
"context"
@ -15,6 +15,8 @@ import (
type (
BasicResponse = core.BasicResponse
DeviceLoginReq = core.DeviceLoginReq
DeviceLoginResp = core.DeviceLoginResp
Empty = core.Empty
GetNodeInfoReq = core.GetNodeInfoReq
GetNodeInfoResp = core.GetNodeInfoResp
@ -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...)
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}

View File

@ -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
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,
DB: db,
Redis: rds,
DeviceRepo: repo.NewDeviceRepo(db),
}
}

View File

@ -13,9 +13,10 @@ 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,
LoginType: loginType,
IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expire) * time.Second)),

View File

@ -24,4 +24,6 @@ const (
SignatureInvalid = 7003
SignatureReplay = 7004
DecryptFailed = 7005
DeviceLoginDisabled = 8001
DeviceNotEnabled = 8002
)

View File

@ -24,6 +24,8 @@ var codeText = map[int]string{
SignatureInvalid: "签名错误",
SignatureReplay: "重放攻击",
DecryptFailed: "解密失败",
DeviceLoginDisabled: "设备登录未启用",
DeviceNotEnabled: "设备已禁用",
}
func IsCodeErr(errcode uint32) bool {