Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions sysken-pay-backend/app/domain/object/user/get_user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package user

import "time"

//TODO モデル(データベースから取得する型を宣言する)
//データベースの制約通りになるようにエラーハンドリングをガチる
//ユーザーID、名前、作成日時、更新日時など

func NewUserFromDB(
userID string,
userName string,
createdAt time.Time,
updatedAt time.Time,
deletedAt time.Time,
) (*User, error) {
user, err := NewUser(userID, userName)
if err != nil {
return nil, err
}

if err := user.SetCreatedAt(createdAt); err != nil {
return nil, err
}
if err := user.SetUpdatedAt(updatedAt); err != nil {
return nil, err
}
if !deletedAt.IsZero() {
if err := user.SetDeletedAt(deletedAt); err != nil {
return nil, err
}
}

return user, nil
}
11 changes: 11 additions & 0 deletions sysken-pay-backend/app/domain/object/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ package user

import (
"errors"
"regexp"
"time"
"unicode/utf8"
)

// 学籍番号フォーマット: 2桁の数字 + 1文字のアルファベット + 数字 (例: 20K23099)
var userIDPattern = regexp.MustCompile(`^\d{2}[A-Za-z]\d+$`)

// ErrUserAlreadyExists は同じIDのユーザーが既に登録されている場合に返されます。
var ErrUserAlreadyExists = errors.New("user already exists")

//TODO モデル(データベースに入れる型を宣言する)
//データベースの制約通りになるようにエラーハンドリングをガチる
//ユーザーID、名前、作成日時、更新日時など
Expand All @@ -27,6 +34,10 @@ func (p *User) SetUserID(userID string) error {
if utf8.RuneCountInString(userID) > 20 {
return errors.New("userID must be 20 characters or less")
}
// 学籍番号フォーマット (例: 20K23099)
if !userIDPattern.MatchString(userID) {
return errors.New("userID must be in student ID format (e.g., 20K23099)")
}

p.userID = userID
return nil
Expand Down
45 changes: 26 additions & 19 deletions sysken-pay-backend/app/domain/object/user/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

func TestSetUserID_Valid(t *testing.T) {
u := &User{}
if err := u.SetUserID("user123"); err != nil {
if err := u.SetUserID("20K23099"); err != nil {
t.Errorf("SetUserID should succeed: %v", err)
}
}
Expand All @@ -23,31 +23,37 @@ func TestSetUserID_Empty(t *testing.T) {

func TestSetUserID_Exactly20Chars(t *testing.T) {
u := &User{}
id := strings.Repeat("a", 20)
// 学籍番号フォーマットで20文字 (2桁数字 + 1文字 + 17桁数字)
id := "20K" + strings.Repeat("1", 17)
if err := u.SetUserID(id); err != nil {
t.Errorf("SetUserID 20 chars should succeed: %v", err)
}
}

func TestSetUserID_Over20Chars(t *testing.T) {
u := &User{}
id := strings.Repeat("a", 21)
id := "20K" + strings.Repeat("1", 18) // 21 chars
if err := u.SetUserID(id); err == nil {
t.Error("SetUserID 21 chars should fail")
}
}

func TestSetUserID_MultibyteCounts(t *testing.T) {
func TestSetUserID_InvalidFormat(t *testing.T) {
u := &User{}
// 日本語20文字はOK
id := strings.Repeat("あ", 20)
if err := u.SetUserID(id); err != nil {
t.Errorf("SetUserID 20 multibyte chars should succeed: %v", err)
}
// 21文字はNG
id21 := strings.Repeat("あ", 21)
if err := u.SetUserID(id21); err == nil {
t.Error("SetUserID 21 multibyte chars should fail")
cases := []string{
"abcdef", // アルファベットのみ
"user123", // 数字2桁スタートでない
"123", // 全数字
"20K", // 末尾の数字なし
"20KK23099", // アルファベット2文字
"K20K23099", // 先頭が数字でない
"2-K23099", // 記号混入
strings.Repeat("あ", 8), // マルチバイトはフォーマット違反
}
for _, id := range cases {
if err := u.SetUserID(id); err == nil {
t.Errorf("SetUserID(%q) should fail format validation", id)
}
}
}

Expand Down Expand Up @@ -98,12 +104,12 @@ func TestSetUserName_MultibyteCounts(t *testing.T) {
// --- NewUser ---

func TestNewUser_Valid(t *testing.T) {
u, err := NewUser("student001", "田中 太郎")
u, err := NewUser("20K23099", "田中 太郎")
if err != nil {
t.Fatalf("NewUser should succeed: %v", err)
}
if u.ID() != "student001" {
t.Errorf("ID() = %s, want student001", u.ID())
if u.ID() != "20K23099" {
t.Errorf("ID() = %s, want 20K23099", u.ID())
}
if u.UserName() != "田中 太郎" {
t.Errorf("UserName() = %s, want 田中 太郎", u.UserName())
Expand All @@ -117,19 +123,20 @@ func TestNewUser_EmptyUserID(t *testing.T) {
}

func TestNewUser_EmptyUserName(t *testing.T) {
if _, err := NewUser("student001", ""); err == nil {
if _, err := NewUser("20K23099", ""); err == nil {
t.Error("NewUser empty userName should fail")
}
}

func TestNewUser_UserIDTooLong(t *testing.T) {
if _, err := NewUser(strings.Repeat("a", 21), "田中 太郎"); err == nil {
id := "20K" + strings.Repeat("1", 18) // 21 chars
if _, err := NewUser(id, "田中 太郎"); err == nil {
t.Error("NewUser userID > 20 chars should fail")
}
}

func TestNewUser_UserNameTooLong(t *testing.T) {
if _, err := NewUser("student001", strings.Repeat("a", 51)); err == nil {
if _, err := NewUser("20K23099", strings.Repeat("a", 51)); err == nil {
t.Error("NewUser userName > 50 chars should fail")
}
}
3 changes: 3 additions & 0 deletions sysken-pay-backend/app/domain/repository/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
//データベースで必要な入力と出力のインターフェースの作成

type UserRepository interface {
// ユーザーIDでユーザー情報を取得する
GetUserByID(ctx context.Context, userID string) (*user.User, error)

// ユーザーを新規作成して保存する
// 保存に成功した場合は保存したユーザーを返す
InsertUser(ctx context.Context, u *user.User) (*user.User, error)
Expand Down
41 changes: 41 additions & 0 deletions sysken-pay-backend/app/infra/repository/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ package repository
import (
"context"
"database/sql"
"errors"
"log"
"sysken-pay-api/app/domain/object/user"
"sysken-pay-api/app/domain/repository"
"time"

"github.com/go-sql-driver/mysql"
)

// mysqlErrDupEntry は重複キー違反 (Duplicate entry) を示す MySQL のエラー番号です。
const mysqlErrDupEntry = 1062

//TODO userデータベースに値を入れる
// domainのrepositoryの中にあるユーザーのインターフェースの実装をする

Expand All @@ -22,6 +28,37 @@ func NewUserProfileRepository(db *sql.DB) *UserRepositoryImpl {
return &UserRepositoryImpl{db: db}
}

func (r *UserRepositoryImpl) GetUserByID(ctx context.Context, userID string) (*user.User, error) {
executor := getExecutor(ctx, r.db)

row := executor.QueryRowContext(ctx, `
SELECT id, name, created_at, updated_at, deleted_at
FROM `+"`user`"+`
WHERE id = ? AND deleted_at IS NULL
`, userID)

var (
id string
name string
createdAt time.Time
updatedAt time.Time
deletedAt sql.NullTime
)
if err := row.Scan(&id, &name, &createdAt, &updatedAt, &deletedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}

var deleted time.Time
if deletedAt.Valid {
deleted = deletedAt.Time
}

return user.NewUserFromDB(id, name, createdAt, updatedAt, deleted)
}

func (r *UserRepositoryImpl) InsertUser(
ctx context.Context, u *user.User) (*user.User, error) {

Expand All @@ -37,6 +74,10 @@ func (r *UserRepositoryImpl) InsertUser(
)

if err != nil {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == mysqlErrDupEntry {
return nil, user.ErrUserAlreadyExists
}
log.Printf("Failed to insert user: %v", err)
return nil, err
}
Expand Down
6 changes: 4 additions & 2 deletions sysken-pay-backend/app/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func Run(db *sql.DB) error {
balanceRepo := repository.NewBalanceRepository(db)

// UseCase
getUserUseCase := user.NewGetUserUseCase(userRepo)
registerUserUseCase := user.NewRegisterUserUseCase(userRepo)
updateUserUseCase := user.NewUpdateUserUseCase(userRepo)
registerItemUseCase := item.NewRegisterItemUseCase(itemRepo)
Expand All @@ -70,7 +71,7 @@ func Run(db *sql.DB) error {
getPurchaseHistoriesUseCase := balance.NewGetPurchaseHistoriesUseCase(balanceRepo)

// Handler
userHandler := api_user.NewUserHandler(registerUserUseCase, updateUserUseCase)
userHandler := api_user.NewUserHandler(getUserUseCase, registerUserUseCase, updateUserUseCase)
itemHandler := api_item.NewItemHandler(registerItemUseCase, updateItemUseCase, findItemByJanCodeUseCase, getAllItemsUseCase)
chargeHandler := api_charge.NewChargeHandler(chargeAmountUseCase, chargeCancelUseCase)
purchaseHandler := api_purchase.NewPurchaseHandler(createPurchaseUseCase, cancelPurchaseUseCase)
Expand All @@ -93,8 +94,9 @@ func Run(db *sql.DB) error {
r.Route("/v1", func(r chi.Router) {
// ユーザー関連
r.Post("/user", userHandler.RegisterUser)
r.Patch("/user/{user_id}", userHandler.UpdateUser)
r.Route("/user/{user_id}", func(r chi.Router) {
r.Get("/", userHandler.GetUser)
r.Patch("/", userHandler.UpdateUser)
Comment thread
sana-sagegami marked this conversation as resolved.
r.Post("/charge", chargeHandler.ChargeAmount)
r.Post("/charge/cancel", chargeHandler.ChargeCancel)
r.Post("/purchase", purchaseHandler.CreatePurchase)
Expand Down
3 changes: 2 additions & 1 deletion sysken-pay-backend/app/ui/api/balance/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package balance

import (
domainbalance "sysken-pay-api/app/domain/object/balance"
"sysken-pay-api/app/ui/api/pkg/timefmt"
"sysken-pay-api/app/usecase/balance"
)

Expand Down Expand Up @@ -50,7 +51,7 @@ func toGetPurchaseHistoriesResponse(
ItemName: h.ItemName(),
Quantity: h.Quantity(),
Price: h.Price(),
PurchaseAt: h.PurchaseAt().Format("2006-01-02T15:04:05.000Z"),
PurchaseAt: timefmt.JST(h.PurchaseAt()),
}
}
return &GetPurchaseHistoriesResponse{
Expand Down
4 changes: 2 additions & 2 deletions sysken-pay-backend/app/ui/api/charge/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ func (h *chargeHandlerImpl) ChargeAmount(w http.ResponseWriter, r *http.Request)
//レスポンスの作成
res := toPostChargeResponse(chargedAmount)

w.Header().Set("Content -Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(res); err != nil {
log.Printf("Failed to encode response: %v", err)
apierrors.RespondError(w, http.StatusInternalServerError, err.Error())
Expand Down
5 changes: 3 additions & 2 deletions sysken-pay-backend/app/ui/api/charge/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package charge

import (
"sysken-pay-api/app/domain/object/charge"
"sysken-pay-api/app/ui/api/pkg/timefmt"
)

type ChargeResponse struct {
Expand All @@ -20,7 +21,7 @@ func toPostChargeResponse(charge *charge.Charge) *ChargeResponse {
Amount: charge.Amount(),
UserID: charge.UserID(),
Balance: charge.Balance(),
CreatedAt: charge.CreatedAt().Format("2006-01-02T15:04:05.000Z"),
CreatedAt: timefmt.JST(charge.CreatedAt()),
}
}

Expand All @@ -40,6 +41,6 @@ func toPostChargeCancelResponse(charge *charge.Charge) *ChargeCancelResponse {
Amount: charge.Amount(),
UserID: charge.UserID(),
Balance: charge.Balance(),
CreatedAt: charge.CreatedAt().Format("2006-01-02T15:04:05.000Z"),
CreatedAt: timefmt.JST(charge.CreatedAt()),
}
}
2 changes: 1 addition & 1 deletion sysken-pay-backend/app/ui/api/item/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (h *itemHandlerImpl) ResisterItem(w http.ResponseWriter, r *http.Request) {
res := toPostItemResponse(createdItem)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(res); err != nil {
apierrors.RespondError(w, http.StatusBadRequest, err.Error())
return
Expand Down
13 changes: 8 additions & 5 deletions sysken-pay-backend/app/ui/api/item/response.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package item

import "sysken-pay-api/app/domain/object/item"
import (
"sysken-pay-api/app/domain/object/item"
"sysken-pay-api/app/ui/api/pkg/timefmt"
)

// PostItemResponse Response: 商品登録のレスポンス
type PostItemResponse struct {
Expand All @@ -20,8 +23,8 @@ func toPostItemResponse(item *item.Item) *PostItemResponse {
JanCode: item.JanCode(),
ItemName: item.Name(),
Price: item.Price(),
CreatedAt: item.CreatedAt().Format("2006-01-02T15:04:05.000Z"),
UpdatedAt: item.UpdatedAt().Format("2006-01-02T15:04:05.000Z"),
CreatedAt: timefmt.JST(item.CreatedAt()),
UpdatedAt: timefmt.JST(item.UpdatedAt()),
}
}

Expand All @@ -43,8 +46,8 @@ func toPatchItemResponse(item *item.Item) *PatchItemResponse {
JanCode: item.JanCode(),
ItemName: item.Name(),
Price: item.Price(),
CreatedAt: item.CreatedAt().Format("2006-01-02T15:04:05.000Z"),
UpdatedAt: item.UpdatedAt().Format("2006-01-02T15:04:05.000Z"),
CreatedAt: timefmt.JST(item.CreatedAt()),
UpdatedAt: timefmt.JST(item.UpdatedAt()),
}
}

Expand Down
11 changes: 11 additions & 0 deletions sysken-pay-backend/app/ui/api/pkg/timefmt/timefmt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package timefmt

import "time"

var jst = time.FixedZone("JST", 9*60*60)

// JST formats t in JST (Asia/Tokyo, +09:00) with millisecond precision.
// 例: 2025-01-01T09:00:00.000+09:00
func JST(t time.Time) string {
return t.In(jst).Format("2006-01-02T15:04:05.000-07:00")
}
1 change: 1 addition & 0 deletions sysken-pay-backend/app/ui/api/purchase/purchase.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func (h *purchaseHandlerImpl) CreatePurchase(w http.ResponseWriter, r *http.Requ

res := toPostPurchaseResponse(createdPurchase)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(res); err != nil {
apierrors.RespondError(w, http.StatusInternalServerError, err.Error())
}
Expand Down
3 changes: 2 additions & 1 deletion sysken-pay-backend/app/ui/api/purchase/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package purchase

import (
"sysken-pay-api/app/domain/object/purchase"
"sysken-pay-api/app/ui/api/pkg/timefmt"
)

type PostPurchaseResponse struct {
Expand Down Expand Up @@ -60,6 +61,6 @@ func toPostPurchaseCancelResponse(p *purchase.Purchase) *PostPurchaseCancelRespo
ID: p.ID(),
UserID: p.UserID(),
Items: items,
CreatedAt: p.CreatedAt().Format("2006-01-02T15:04:05.000Z"),
CreatedAt: timefmt.JST(p.CreatedAt()),
}
}
Loading