Files
2026-04-27 10:35:03 +08:00

183 lines
6.2 KiB
Go

/*
SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
SPDX-License-Identifier: MIT
*/
package service
import (
"context"
v2 "stackChan/api/user/v2"
"stackChan/internal/dao"
"stackChan/internal/model"
"stackChan/internal/model/entity"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/guid"
"github.com/golang-jwt/jwt/v5"
)
const (
loginUrl = "https://forum.m5stack.com/api/v3/utilities/login"
TokenExpire = 365 * 24 * time.Hour
Issuer = "stackChan"
Audience = "stackChan_user"
RegistrationUrl = "https://forum.m5stack.com/api/v3/users"
RegistrationToken = "Bearer 656515ad-f72f-499f-b716-5db17181642c"
)
// Login User login
func Login(ctx context.Context, req *v2.LoginReq) (res *v2.LoginRes, err error) {
if req.Username == "" || req.Password == "" {
return nil, gerror.NewCode(gcode.CodeMissingParameter, "Username / Password cannot be left blank.")
}
remoteResp, err := callRemoteLogin(ctx, req)
if err != nil {
return nil, err
}
if remoteResp == nil {
return nil, gerror.NewCode(gcode.CodeInvalidParameter, "invalid parameter")
}
if err = saveUserToLocal(ctx, remoteResp); err != nil {
return nil, err
}
token, err := generateToken(remoteResp.Response.Uid)
if err != nil {
return nil, err
}
return &v2.LoginRes{
Token: token,
}, nil
}
// callRemoteLogin Call remote login interface
func callRemoteLogin(ctx context.Context, req *v2.LoginReq) (*model.RemoteLoginResp, error) {
remoteLoginResp := &model.RemoteLoginResp{}
clientResp := g.Client().PostVar(ctx, loginUrl, g.Map{
"username": req.Username,
"password": req.Password,
})
if clientResp == nil {
g.Log().Errorf(ctx, "Remote login no response, username=%s", req.Username)
return nil, gerror.NewCode(gcode.CodeInternalError, "remote service unavailable")
}
respBody := clientResp.String()
g.Log().Debugf(ctx, "Remote login raw response: %s", respBody)
if strings.Contains(respBody, "[[error:") {
g.Log().Errorf(ctx, "Remote login failed: %s", respBody)
return nil, gerror.NewCode(gcode.CodeBusinessValidationFailed, respBody)
}
err := clientResp.Scan(&remoteLoginResp)
if err != nil {
g.Log().Errorf(ctx, "Login response parsing failed: %+v, raw response: %s", err, respBody)
return nil, gerror.WrapCode(gcode.CodeInternalError, err, respBody)
}
if remoteLoginResp.Status.Code != "ok" {
errMsg := remoteLoginResp.Status.Message
g.Log().Errorf(ctx, "Remote login failed: %s", errMsg)
return nil, gerror.NewCode(gcode.CodeBusinessValidationFailed, remoteLoginResp.Status.Message, errMsg)
}
return remoteLoginResp, nil
}
// saveUserToLocal Save user to local database
func saveUserToLocal(ctx context.Context, resp *model.RemoteLoginResp) error {
data := entity.User{
Uid: resp.Response.Uid,
Username: resp.Response.Username,
Userslug: resp.Response.Userslug,
DisplayName: resp.Response.Displayname,
IconText: resp.Response.IconText,
IconBgColor: resp.Response.IconBgColor,
EmailConfirmed: resp.Response.EmailConfirmed,
JoinDate: resp.Response.Joindate,
LastOnline: resp.Response.Lastonline,
UserStatus: resp.Response.Status,
}
_, err := dao.User.Ctx(ctx).Save(data)
if err != nil {
return gerror.WrapCode(gcode.CodeDbOperationError, err, "Failed to write user to local database")
}
return nil
}
// generateToken Generate JWT token, includes user UID, issuer, audience, issued time, expiration time
func generateToken(uid int64) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"jti": guid.S(), // Unique token ID (for revocation/blacklisting)
"id": uid, // User UID
"iss": Issuer, // Issuer (for verification and anti-forgery)
"aud": Audience, // Audience (to limit scope of use)
"iat": now.Unix(), // Issued at time
"exp": now.Add(TokenExpire).Unix(), // Expiration time
}
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
jwtSecret := GetJwtSecret()
if jwtSecret == "" || len(jwtSecret) < 16 {
return "", gerror.NewCode(gcode.CodeInternalError, "JWT secret is empty or too weak")
}
token, err := tokenObj.SignedString([]byte(jwtSecret))
if err != nil {
return "", gerror.WrapCode(gcode.CodeInternalError, err, "Failed to generate token")
}
return token, nil
}
// Registration User registration
func Registration(ctx context.Context, req *v2.RegistrationReq) (res *v2.RegistrationRes, err error) {
if req.UserName == "" || req.Password == "" || req.Email == "" {
return nil, gerror.NewCode(gcode.CodeMissingParameter, "Username/Email/Password cannot be empty")
}
remoteResp, err := callRemoteRegister(ctx, req)
if err != nil {
return nil, err
}
responseData := v2.RegistrationRes(remoteResp)
return &responseData, nil
}
// callRemoteRegister Call remote registration interface
func callRemoteRegister(ctx context.Context, req *v2.RegistrationReq) (res *model.RegistrationResponse, err error) {
resp := &model.RemoteRegisterResp{}
g.Log().Infof(ctx, "Remote registration request parameters: username=%s, email=%s", req.UserName, req.Email)
clientResp := g.Client().
SetHeader("Authorization", RegistrationToken).
PostVar(ctx, RegistrationUrl, g.Map{
"username": req.UserName,
"email": req.Email,
"password": req.Password,
})
if clientResp == nil {
return nil, gerror.NewCode(gcode.CodeInternalError, "remote service unavailable")
}
respBody := clientResp.String()
g.Log().Debugf(ctx, "Remote registration raw response: %s", respBody)
if strings.Contains(respBody, "[[error:") {
g.Log().Errorf(ctx, "Remote registration failed: %s", respBody)
return nil, gerror.NewCode(gcode.CodeBusinessValidationFailed, respBody)
}
err = clientResp.Scan(&resp)
if err != nil {
g.Log().Errorf(ctx, "Registration response parsing failed: %+v, raw response: %s", err, respBody)
return nil, gerror.WrapCode(gcode.CodeInternalError, err, respBody)
}
if resp.Status.Code != "ok" {
g.Log().Errorf(ctx, "Remote registration business failed: code=%s, message=%s", resp.Status.Code, resp.Status.Message)
return nil, gerror.NewCodef(gcode.CodeBusinessValidationFailed, resp.Status.Message)
}
return &resp.RegistrationResponse, nil
}