feat: initial commit - kratos admin service

This commit is contained in:
yvan
2026-08-13 18:22:43 +08:00
commit be5362913b
183 changed files with 16186 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Biz
+136
View File
@@ -0,0 +1,136 @@
package biz
import (
"context"
"time"
"github.com/go-kratos/kratos/v3/errors"
"github.com/go-kratos/kratos/v3/log"
"golang.org/x/crypto/bcrypt"
)
// Admin is a Admin model.
type Admin struct {
ID int64
Name string
Email string
Password string
Access string
Avatar string
CreateTime time.Time
UpdateTime time.Time
}
// AdminRepo is a Greater repo.
type AdminRepo interface {
FindByID(context.Context, int64) (*Admin, error)
FindByName(context.Context, string) (*Admin, error)
FindByEmail(context.Context, string) (*Admin, error)
ListAdmins(context.Context, ...ListOption) ([]*Admin, error)
CreateAdmin(context.Context, *Admin) (*Admin, error)
UpdateAdmin(context.Context, *Admin) (*Admin, error)
DeleteAdmin(context.Context, int64) error
}
// AdminUsecase is a Admin usecase.
type AdminUsecase struct {
admin AdminRepo
}
// NewAdminUsecase new a Admin usecase.
func NewAdminUsecase(repo AdminRepo) *AdminUsecase {
return &AdminUsecase{admin: repo}
}
// LoginByUsername logs in a user by username and password.
func (uc *AdminUsecase) LoginByUsername(ctx context.Context, username, password string) (*Admin, error) {
user, err := uc.admin.FindByName(ctx, username)
if err != nil {
if errors.Is(err, ErrAdminNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return user, nil
}
// LoginByEmail logs in a user by email and password.
func (uc *AdminUsecase) LoginByEmail(ctx context.Context, email, password string) (*Admin, error) {
user, err := uc.admin.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, ErrAdminNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return user, nil
}
// Logout logs out the current user.
func (uc *AdminUsecase) Logout(ctx context.Context, adminID int64) error {
admin, err := uc.admin.FindByID(ctx, adminID)
if err != nil {
return err
}
log.InfoContext(ctx, "admin logged out", "name", admin.Name)
return nil
}
// Current returns the current logged in user.
func (uc *AdminUsecase) GetAdmin(ctx context.Context, id int64) (*Admin, error) {
return uc.admin.FindByID(ctx, id)
}
// ListAdmins lists admin users with pagination.
func (uc *AdminUsecase) ListAdmins(ctx context.Context, opts ...ListOption) ([]*Admin, error) {
admins, err := uc.admin.ListAdmins(ctx, opts...)
if err != nil {
return nil, err
}
return admins, nil
}
// CreateAdmin creates a new admin user.
func (uc *AdminUsecase) CreateAdmin(ctx context.Context, admin *Admin) (*Admin, error) {
if admin.Password != "" {
hashed, err := hashPassword(admin.Password)
if err != nil {
return nil, err
}
admin.Password = hashed
}
return uc.admin.CreateAdmin(ctx, admin)
}
// UpdateAdmin updates an existing admin user.
func (uc *AdminUsecase) UpdateAdmin(ctx context.Context, admin *Admin) (*Admin, error) {
// Empty password means "leave unchanged"; only hash when a new one is set.
if admin.Password != "" {
hashed, err := hashPassword(admin.Password)
if err != nil {
return nil, err
}
admin.Password = hashed
}
return uc.admin.UpdateAdmin(ctx, admin)
}
// hashPassword hashes a plaintext password using bcrypt.
func hashPassword(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
// DeleteAdmin deletes an admin user by ID.
func (uc *AdminUsecase) DeleteAdmin(ctx context.Context, id int64) error {
return uc.admin.DeleteAdmin(ctx, id)
}
+166
View File
@@ -0,0 +1,166 @@
package biz
import (
"context"
"testing"
"github.com/go-kratos/kratos/v3/errors"
"golang.org/x/crypto/bcrypt"
)
// fakeAdminRepo is an in-memory AdminRepo for testing the usecase in
// isolation from the data layer.
type fakeAdminRepo struct {
byName map[string]*Admin
byEmail map[string]*Admin
created []*Admin
}
func newFakeAdminRepo() *fakeAdminRepo {
return &fakeAdminRepo{
byName: make(map[string]*Admin),
byEmail: make(map[string]*Admin),
}
}
func (r *fakeAdminRepo) FindByID(context.Context, int64) (*Admin, error) {
return nil, ErrAdminNotFound
}
func (r *fakeAdminRepo) FindByName(_ context.Context, name string) (*Admin, error) {
a, ok := r.byName[name]
if !ok {
return nil, ErrAdminNotFound
}
return a, nil
}
func (r *fakeAdminRepo) FindByEmail(_ context.Context, email string) (*Admin, error) {
a, ok := r.byEmail[email]
if !ok {
return nil, ErrAdminNotFound
}
return a, nil
}
func (r *fakeAdminRepo) ListAdmins(context.Context, ...ListOption) ([]*Admin, error) {
return nil, nil
}
func (r *fakeAdminRepo) CreateAdmin(_ context.Context, a *Admin) (*Admin, error) {
r.created = append(r.created, a)
return a, nil
}
func (r *fakeAdminRepo) UpdateAdmin(_ context.Context, a *Admin) (*Admin, error) {
return a, nil
}
func (r *fakeAdminRepo) DeleteAdmin(context.Context, int64) error {
return nil
}
// seedAdmin inserts an admin with a bcrypt-hashed password.
func seedAdmin(t *testing.T, repo *fakeAdminRepo, name, email, password string) *Admin {
t.Helper()
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
a := &Admin{ID: 1, Name: name, Email: email, Password: string(hashed)}
repo.byName[name] = a
repo.byEmail[email] = a
return a
}
func TestLoginByUsername_Success(t *testing.T) {
repo := newFakeAdminRepo()
seedAdmin(t, repo, "admin", "admin@example.com", "admin")
uc := NewAdminUsecase(repo)
got, err := uc.LoginByUsername(context.Background(), "admin", "admin")
if err != nil {
t.Fatalf("expected login success, got error: %v", err)
}
if got.Name != "admin" {
t.Fatalf("expected admin user, got %q", got.Name)
}
}
func TestLoginByEmail_Success(t *testing.T) {
repo := newFakeAdminRepo()
seedAdmin(t, repo, "admin", "admin@example.com", "admin")
uc := NewAdminUsecase(repo)
got, err := uc.LoginByEmail(context.Background(), "admin@example.com", "admin")
if err != nil {
t.Fatalf("expected login success, got error: %v", err)
}
if got.Email != "admin@example.com" {
t.Fatalf("expected admin user, got %q", got.Email)
}
}
func TestLoginByUsername_WrongPassword(t *testing.T) {
repo := newFakeAdminRepo()
seedAdmin(t, repo, "admin", "admin@example.com", "admin")
uc := NewAdminUsecase(repo)
_, err := uc.LoginByUsername(context.Background(), "admin", "wrong")
if err == nil {
t.Fatal("expected error for wrong password, got nil")
}
if errors.Code(err) != 401 {
t.Fatalf("expected 401 unauthorized, got code %d (%v)", errors.Code(err), err)
}
}
func TestLoginByUsername_UserNotFound(t *testing.T) {
repo := newFakeAdminRepo()
uc := NewAdminUsecase(repo)
_, err := uc.LoginByUsername(context.Background(), "ghost", "admin")
if err == nil {
t.Fatal("expected error for unknown user, got nil")
}
// Must be the same opaque 401 as a wrong password, to avoid user enumeration.
if errors.Code(err) != 401 {
t.Fatalf("expected 401 unauthorized, got code %d (%v)", errors.Code(err), err)
}
if errors.Is(err, ErrAdminNotFound) {
t.Fatal("login must not leak ErrAdminNotFound for unknown users")
}
}
func TestCreateAdmin_HashesPassword(t *testing.T) {
repo := newFakeAdminRepo()
uc := NewAdminUsecase(repo)
_, err := uc.CreateAdmin(context.Background(), &Admin{Name: "bob", Password: "secret"})
if err != nil {
t.Fatalf("create admin: %v", err)
}
if len(repo.created) != 1 {
t.Fatalf("expected 1 created admin, got %d", len(repo.created))
}
stored := repo.created[0].Password
if stored == "secret" {
t.Fatal("password was stored in plaintext")
}
if err := bcrypt.CompareHashAndPassword([]byte(stored), []byte("secret")); err != nil {
t.Fatalf("stored password is not a valid bcrypt hash of the input: %v", err)
}
}
func TestUpdateAdmin_EmptyPasswordLeftUnchanged(t *testing.T) {
repo := newFakeAdminRepo()
uc := NewAdminUsecase(repo)
got, err := uc.UpdateAdmin(context.Background(), &Admin{ID: 1, Name: "bob", Password: ""})
if err != nil {
t.Fatalf("update admin: %v", err)
}
if got.Password != "" {
t.Fatalf("empty password must be passed through unchanged, got %q", got.Password)
}
}
+6
View File
@@ -0,0 +1,6 @@
package biz
import "github.com/google/wire"
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(NewAdminUsecase)
+14
View File
@@ -0,0 +1,14 @@
package biz
import (
v1 "kra/api/kratos/admin/v1"
"github.com/go-kratos/kratos/v3/errors"
)
var (
// ErrAdminNotFound error admin not found.
ErrAdminNotFound = errors.NotFound(v1.ErrorReason_ADMIN_NOT_FOUND.String(), "admin not found")
// ErrInvalidCredentials is returned for any failed login, regardless of
// whether the user exists, to avoid leaking which accounts are registered.
ErrInvalidCredentials = errors.Unauthorized(v1.ErrorReason_INVALID_CREDENTIALS.String(), "invalid credentials")
)
+39
View File
@@ -0,0 +1,39 @@
package biz
import (
"go.einride.tech/aip/filtering"
"go.einride.tech/aip/ordering"
)
type ListOption func(*ListOptions)
type ListOptions struct {
Filter filtering.Filter
OrderBy ordering.OrderBy
Offset int
Limit int
}
func ListFilter(filter filtering.Filter) ListOption {
return func(o *ListOptions) {
o.Filter = filter
}
}
func ListOrderBy(orderBy ordering.OrderBy) ListOption {
return func(o *ListOptions) {
o.OrderBy = orderBy
}
}
func ListOffset(offset int) ListOption {
return func(o *ListOptions) {
o.Offset = offset
}
}
func ListLimit(limit int) ListOption {
return func(o *ListOptions) {
o.Limit = limit
}
}