From 347555fab08a5af4ca6106711b1e5575250e4931 Mon Sep 17 00:00:00 2001 From: GP Date: Mon, 20 Apr 2026 12:56:34 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=9C=BA?= =?UTF-8?q?=E9=A6=86=E6=9F=A5=E8=AF=A2=E5=92=8C=E5=9C=BA=E6=AC=A1=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E4=B8=A4=E4=B8=AA=20GET=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ListVenues / ListSlots 对接 TYYS API - 修复数值类型 ID 解析问题 - OpenAPI 补充可选值文档 Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + api/openapi/openapi.yaml | 37 ++++- cmd/server/main.go | 13 +- internal/service/reservation_service.go | 186 +++++++++++++++++++++++- 4 files changed, 231 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 1832afd..b495e5f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ __pycache__/ .claude/ docs/ prd.md +claude.md \ No newline at end of file diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 905ad9c..5521b63 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -483,13 +483,21 @@ paths: get: operationId: listReservationVenues summary: List supported reservation venues + description: | + 返回所有可预约的场馆列表,支持按球类(sport_type)和校区(campus)筛选。 + 可选值来自 TYYS 体育馆系统: + - **sport_type** 可选值:羽毛球、健身、游泳、网球 + - **campus** 可选值:紫金港校区、华家池校区、玉泉校区、西溪校区 + 示例:sport_type=羽毛球&campus=紫金港 返回紫金港校区所有羽毛球场馆 parameters: - in: query name: sport_type + description: 球类类型,如羽毛球、健身、游泳、网球 schema: type: string - in: query name: campus + description: 校区名称,如紫金港校区、华家池校区、玉泉校区、西溪校区 schema: type: string responses: @@ -502,29 +510,43 @@ paths: /reservations/slots: get: operationId: listReservationSlots - summary: List supported reservation slots + summary: List available time slots for a venue + description: | + 查询指定场馆在某日期的可预约时间段。 + - **sport_type** 可选值:羽毛球、健身、游泳、网球 + - **campus_name** 可选值:紫金港校区、华家池校区、玉泉校区、西溪校区 + - **venue_name** 可选值(按校区和球类不同):如风雨操场、体育馆、羽毛球馆、游泳馆等 + - **reservation_date** 格式:YYYY-MM-DD,如 2026-04-21 parameters: - in: query name: sport_type + description: 球类类型。可选值:羽毛球、健身、游泳、网球 required: true schema: type: string + example: 羽毛球 - in: query name: campus_name + description: 校区名称。可选值:紫金港校区、华家池校区、玉泉校区、西溪校区 required: true schema: type: string + example: 紫金港校区 - in: query name: venue_name + description: 场馆名称 required: true schema: type: string + example: 风雨操场 - in: query name: reservation_date + description: 预约日期,格式 YYYY-MM-DD required: true schema: type: string format: date + example: "2026-04-21" responses: '200': description: Reservation slot list @@ -1061,13 +1083,17 @@ components: ReservationVenue: type: object required: [sport_type, campus_name, venue_name] + description: 场馆信息 properties: sport_type: type: string + description: 球类类型。可选值:羽毛球、健身、游泳、网球 campus_name: type: string + description: 校区名称。可选值:紫金港校区、华家池校区、玉泉校区、西溪校区 venue_name: type: string + description: 场馆名称 ReservationVenueListResponse: type: object required: [items] @@ -1079,17 +1105,26 @@ components: ReservationSlot: type: object required: [slot_key, start_time, end_time, available] + description: 预约时间段 properties: slot_key: type: string + description: 时间段唯一标识,用于提交预约 + example: "123456" start_time: type: string + description: 开始时间,格式 HH:mm 或 YYYY-MM-DD HH:mm + example: "2026-04-21 08:30" end_time: type: string + description: 结束时间,格式 HH:mm 或 YYYY-MM-DD HH:mm + example: "2026-04-21 09:30" available: type: boolean + description: 是否可预约 space_name: type: string + description: 场地名称(如羽毛球场1号场) ReservationSlotListResponse: type: object required: [items] diff --git a/cmd/server/main.go b/cmd/server/main.go index 48079ce..260fb5e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,6 +16,7 @@ import ( applog "github.com/QSCTech/SRTP-Backend/internal/logger" "github.com/QSCTech/SRTP-Backend/internal/repository" "github.com/QSCTech/SRTP-Backend/internal/service" + "github.com/QSCTech/SRTP-Backend/internal/zjulogin" "github.com/QSCTech/SRTP-Backend/models" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -70,7 +71,17 @@ func main() { roomRepository := repository.NewRoomRepository(gormDB) roomService := service.NewRoomService(roomRepository, userService) reservationRepository := repository.NewReservationRepository(gormDB) - reservationService := service.NewReservationService(roomRepository, reservationRepository) + + // Initialize ZJUZJL login for TYYS reservation system. + auth, err := zjulogin.NewFromEnv() + if err != nil { + log.Fatal("initialize zjulogin", zap.Error(err)) + } + tyys, err := auth.TYYS() + if err != nil { + log.Fatal("initialize TYYS client", zap.Error(err)) + } + reservationService := service.NewReservationService(roomRepository, reservationRepository, tyys) engine := api.NewRouter(log, sqlDB, userService, roomService, reservationService) server := &http.Server{ diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 0b41f7a..54b491c 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -2,9 +2,14 @@ package service import ( "context" + "encoding/json" "fmt" + "net/url" + "strconv" + "strings" "github.com/QSCTech/SRTP-Backend/internal/repository" + "github.com/QSCTech/SRTP-Backend/internal/zjulogin" "github.com/QSCTech/SRTP-Backend/models" ) @@ -57,18 +62,191 @@ type ReservationPreviewOutput struct { type ReservationService struct { roomRepo *repository.RoomRepository reservationRepo *repository.ReservationRepository + tyys *zjulogin.TYYS } -func NewReservationService(roomRepo *repository.RoomRepository, reservationRepo *repository.ReservationRepository) *ReservationService { - return &ReservationService{roomRepo: roomRepo, reservationRepo: reservationRepo} +func NewReservationService(roomRepo *repository.RoomRepository, reservationRepo *repository.ReservationRepository, tyys *zjulogin.TYYS) *ReservationService { + return &ReservationService{roomRepo: roomRepo, reservationRepo: reservationRepo, tyys: tyys} } func (s *ReservationService) ListVenues(ctx context.Context, sportType, campus *string) []ReservationVenueItem { - return nil + resp, err := s.tyys.VenueInfo(ctx, 0) + if err != nil || resp == nil { + return nil + } + + var result []ReservationVenueItem + walkVenues(resp.Data, func(obj map[string]any) { + sport := trimString(obj["sportName"]) + camp := trimString(obj["campusName"]) + venue := trimString(obj["venueName"]) + + if sport == "" || camp == "" || venue == "" { + return + } + if sportType != nil && *sportType != "" && sport != *sportType { + return + } + if campus != nil && *campus != "" && camp != *campus { + return + } + result = append(result, ReservationVenueItem{ + SportType: sport, + CampusName: camp, + VenueName: venue, + }) + }) + return result +} + +// trimString converts an any value to string, returning empty string if not a string. +func trimString(v any) string { + switch val := v.(type) { + case string: + return val + case float64: + if val == float64(int64(val)) { + return strconv.FormatFloat(val, 'f', 0, 64) + } + return strconv.FormatFloat(val, 'f', -1, 64) + case int: + return strconv.Itoa(val) + } + return "" +} + +// walkVenues parses TYYS venue data and visits each venue object that has sportName field. +// It recursively walks through the JSON data structure to find all venue objects. +func walkVenues(data []byte, visit func(map[string]any)) { + var payload any + if err := json.Unmarshal(data, &payload); err != nil { + return + } + walkJSONObjects(payload, func(obj map[string]any) { + if _, ok := obj["sportName"]; ok { + visit(obj) + } + }) +} + +// walkJSONObjects recursively walks a parsed JSON structure and calls visit for each object. +// It handles both maps and arrays, drilling down into nested structures. +func walkJSONObjects(value any, visit func(map[string]any)) { + switch typed := value.(type) { + case map[string]any: + visit(typed) + for _, child := range typed { + walkJSONObjects(child, visit) + } + case []any: + for _, child := range typed { + walkJSONObjects(child, visit) + } + } +} + +// textMatches is a flexible string matcher for reservation fields. +// It returns true if want is empty or if got contains want or equals want. +func textMatches(got, want string) bool { + want = strings.TrimSpace(want) + if want == "" { + return true + } + got = strings.TrimSpace(got) + return got == want || strings.Contains(got, want) || strings.Contains(want, got) } func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusName, venueName, reservationDate string) ([]ReservationSlotItem, error) { - return nil, fmt.Errorf("reservation service ListSlots not implemented") + // Step 1: Get venue info to find venueId and venueSiteId + venueResp, err := s.tyys.VenueInfo(ctx, 0) + if err != nil { + return nil, fmt.Errorf("get venue info: %w", err) + } + + // Find matching venue and extract IDs + var venueID, venueSiteID string + walkVenues(venueResp.Data, func(obj map[string]any) { + if venueID != "" { + return // already found + } + sportGot := trimString(obj["sportName"]) + campusGot := trimString(obj["campusName"]) + venueGot := trimString(obj["venueName"]) + if !textMatches(sportGot, sportType) { + return + } + if !textMatches(campusGot, campusName) { + return + } + if !textMatches(venueGot, venueName) { + return + } + venueID = trimString(obj["venueId"]) + venueSiteID = trimString(obj["id"]) + }) + + if venueID == "" || venueSiteID == "" { + return nil, fmt.Errorf("venue not found for sport=%s campus=%s venue=%s", sportType, campusName, venueName) + } + + // Step 2: Get day info (available slots) + params := url.Values{} + params.Set("venueId", venueID) + params.Set("venueSiteId", venueSiteID) + params.Set("siteId", venueSiteID) + params.Set("date", reservationDate) + params.Set("reservationDate", reservationDate) + params.Set("searchDate", reservationDate) + + dayResp, err := s.tyys.ReservationDayInfo(ctx, params) + if err != nil { + return nil, fmt.Errorf("get day info: %w", err) + } + + // Step 3: Parse slots from day info response + var slots []ReservationSlotItem + walkSlots(dayResp.Data, func(slot map[string]any) { + item := ReservationSlotItem{ + SlotKey: trimString(slot["timeId"]), + StartTime: trimString(slot["startDate"]), + EndTime: trimString(slot["endDate"]), + Available: isSlotAvailable(slot), + } + if name := trimString(slot["spaceName"]); name != "" { + item.SpaceName = &name + } + slots = append(slots, item) + }) + + return slots, nil +} + +// isSlotAvailable checks if a slot is available for booking. +func isSlotAvailable(slot map[string]any) bool { + if status, ok := slot["reservationStatus"].(float64); ok && status != 1 { + return false + } + if count, ok := slot["alreadyNum"].(float64); ok && count > 0 { + return false + } + if tradeNo := trimString(slot["tradeNo"]); tradeNo != "" && tradeNo != "null" { + return false + } + return true +} + +// walkSlots walks through parsed JSON and visits each slot object. +func walkSlots(data []byte, visit func(map[string]any)) { + var payload any + if err := json.Unmarshal(data, &payload); err != nil { + return + } + walkJSONObjects(payload, func(obj map[string]any) { + // A slot object has startDate and endDate fields + if _, hasStart := obj["startDate"]; hasStart { + visit(obj) + } + }) } func (s *ReservationService) Preview(ctx context.Context, input ReservationPreviewInput) (*ReservationPreviewOutput, error) { From 8d4791486564d35f29f6dccba28093bf92816d74 Mon Sep 17 00:00:00 2001 From: GP Date: Thu, 23 Apr 2026 17:01:36 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E9=A2=84=E8=A7=88=E3=80=81=E6=8F=90=E4=BA=A4=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=8F=8A=E5=9F=BA=E7=A1=80=20Room/User=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reservation_service: 实现 Preview(校验房间状态、同伴码、TYYS 时间段可用性) - reservation_service: 实现 Submit(落库、TYYS 开放时间规则、幂等性校验、attempt log) - reservation_service: 修复 checkSlotAvailable 多球场场景下的可用性判断逻辑 - room_service: 实现 Create(生成邀请码、写库、添加房主为首位成员) - room_service: 实现 GetByID(返回房间及成员列表) - user_service: 实现 Create / GetByID / GetCurrent / LoginOrCreate / UpdateCurrentProfile Co-Authored-By: Claude Sonnet 4.6 --- internal/service/reservation_service.go | 287 +++++++++++++++++++++++- internal/service/room_service.go | 98 +++++++- internal/service/user_service.go | 99 +++++++- 3 files changed, 473 insertions(+), 11 deletions(-) diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 54b491c..924e3f0 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -3,14 +3,17 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "net/url" "strconv" "strings" + "time" "github.com/QSCTech/SRTP-Backend/internal/repository" "github.com/QSCTech/SRTP-Backend/internal/zjulogin" "github.com/QSCTech/SRTP-Backend/models" + "gorm.io/gorm" ) type ReservationVenueItem struct { @@ -249,10 +252,290 @@ func walkSlots(data []byte, visit func(map[string]any)) { }) } +// Preview validates a proposed reservation against the room state and TYYS availability +// without writing anything to the database. The caller uses the returned output to confirm +// details before calling Submit. func (s *ReservationService) Preview(ctx context.Context, input ReservationPreviewInput) (*ReservationPreviewOutput, error) { - return nil, fmt.Errorf("reservation service Preview not implemented") + room, err := s.roomRepo.GetByID(ctx, input.RoomID) + if err != nil { + return nil, fmt.Errorf("room not found: %w", err) + } + if !room.NeedReservation { + return nil, fmt.Errorf("room does not require reservation") + } + // Only active rooms (recruiting / full) may enter the reservation flow. + if room.Status == "cancelled" || room.Status == "finished" { + return nil, fmt.Errorf("room is not active (status=%s)", room.Status) + } + // TYYS requires a buddy/partner code when booking pair courts (羽毛球, 网球). + // Fail early here so the user sees a clear error before any network call. + if sportRequiresBuddyCode(input.SportType) && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { + return nil, fmt.Errorf("sport %s requires a buddy code", input.SportType) + } + + venueID, venueSiteID, err := s.resolveVenueIDs(ctx, input) + if err != nil { + return nil, err + } + + if err := s.checkSlotAvailable(ctx, venueID, venueSiteID, input); err != nil { + return nil, err + } + + // TYYS returns IDs as strings; convert back to uint so the response carries + // the resolved IDs that Submit can reuse without a second venue lookup. + venueIDUint, _ := strconv.ParseUint(venueID, 10, 64) + venueSiteIDUint, _ := strconv.ParseUint(venueSiteID, 10, 64) + venueIDResult := uint(venueIDUint) + venueSiteIDResult := uint(venueSiteIDUint) + + return &ReservationPreviewOutput{ + RoomID: input.RoomID, + Provider: "tyys", + ReservationStatus: "pending", // preview does not submit; status stays pending until Submit succeeds + SportType: input.SportType, + CampusName: input.CampusName, + VenueName: input.VenueName, + ReservationDate: input.ReservationDate, + StartTime: input.StartTime, + EndTime: input.EndTime, + BuddyCode: input.BuddyCode, + VenueID: &venueIDResult, + VenueSiteID: &venueSiteIDResult, + SpaceID: input.SpaceID, + SpaceName: input.SpaceName, + }, nil +} + +// sportRequiresBuddyCode reports whether the TYYS system requires a partner (buddy) code +// for the given sport type. Pair sports (羽毛球, 网球) must have a buddy code in the +// order form; individual or group sports (健身, 游泳) do not. +func sportRequiresBuddyCode(sport string) bool { + switch strings.TrimSpace(sport) { + case "羽毛球", "网球": + return true + } + return false +} + +// resolveVenueIDs returns the TYYS venueId and venueSiteId for the requested venue. +// If the caller already obtained these IDs (e.g. from ListSlots), they are returned +// directly to avoid an extra round-trip to the TYYS venue-info API. +func (s *ReservationService) resolveVenueIDs(ctx context.Context, input ReservationPreviewInput) (venueID, venueSiteID string, err error) { + if input.VenueID != nil && input.VenueSiteID != nil { + return strconv.FormatUint(uint64(*input.VenueID), 10), strconv.FormatUint(uint64(*input.VenueSiteID), 10), nil + } + + venueResp, err := s.tyys.VenueInfo(ctx, 0) + if err != nil { + return "", "", fmt.Errorf("get venue info: %w", err) + } + + // walkVenues has no early-exit mechanism; guard with venueID != "" to stop processing + // after the first match and avoid overwriting with a later duplicate entry. + walkVenues(venueResp.Data, func(obj map[string]any) { + if venueID != "" { + return + } + if !textMatches(trimString(obj["sportName"]), input.SportType) { + return + } + if !textMatches(trimString(obj["campusName"]), input.CampusName) { + return + } + if !textMatches(trimString(obj["venueName"]), input.VenueName) { + return + } + venueID = trimString(obj["venueId"]) + venueSiteID = trimString(obj["id"]) + }) + + if venueID == "" || venueSiteID == "" { + return "", "", fmt.Errorf("venue not found for sport=%s campus=%s venue=%s", input.SportType, input.CampusName, input.VenueName) + } + return venueID, venueSiteID, nil +} + +// checkSlotAvailable queries TYYS day info and confirms the specific start/end time slot +// exists and is free. The TYYS API stores slot times as "YYYY-MM-DD HH:mm", so we +// concatenate reservationDate + " " + time before comparing against startDate/endDate. +func (s *ReservationService) checkSlotAvailable(ctx context.Context, venueID, venueSiteID string, input ReservationPreviewInput) error { + params := url.Values{} + params.Set("venueId", venueID) + params.Set("venueSiteId", venueSiteID) + params.Set("siteId", venueSiteID) // TYYS day-info API accepts both siteId and venueSiteId; send both for compatibility + params.Set("date", input.ReservationDate) + params.Set("reservationDate", input.ReservationDate) + params.Set("searchDate", input.ReservationDate) + + dayResp, err := s.tyys.ReservationDayInfo(ctx, params) + if err != nil { + return fmt.Errorf("get day info: %w", err) + } + + // TYYS slot timestamps are formatted as "YYYY-MM-DD HH:mm". + wantStart := input.ReservationDate + " " + input.StartTime + wantEnd := input.ReservationDate + " " + input.EndTime + found := false + available := false + walkSlots(dayResp.Data, func(slot map[string]any) { + // A venue has multiple courts; the same time window appears once per court. + // Stop early once we find an available court — we don't need all of them. + if available { + return + } + if trimString(slot["startDate"]) == wantStart && trimString(slot["endDate"]) == wantEnd { + found = true + if isSlotAvailable(slot) { + available = true + } + } + }) + + if !found { + return fmt.Errorf("slot not found for %s %s-%s", input.ReservationDate, input.StartTime, input.EndTime) + } + if !available { + return fmt.Errorf("slot %s %s-%s is not available", input.ReservationDate, input.StartTime, input.EndTime) + } + return nil } +// Submit validates the reservation request, writes a RoomReservation record, and +// determines its initial status based on the TYYS opening-time rule: +// - Before the window: status="scheduled", ReserveOpenAt records when to trigger +// - After the window: status="submitting", ready for the scheduler to pick up +// +// The actual TYYS call (which requires captcha solving) is NOT made here; +// it is delegated to POST /internal/tasks/reservation-trigger so the HTTP +// response stays fast and captcha complexity lives in the scheduler. func (s *ReservationService) Submit(ctx context.Context, input ReservationPreviewInput) (*models.RoomReservation, error) { - return nil, fmt.Errorf("reservation service Submit not implemented") + // --- 1. Validate room state (same gates as Preview) --- + room, err := s.roomRepo.GetByID(ctx, input.RoomID) + if err != nil { + return nil, fmt.Errorf("room not found: %w", err) + } + if !room.NeedReservation { + return nil, fmt.Errorf("room does not require reservation") + } + if room.Status == "cancelled" || room.Status == "finished" { + return nil, fmt.Errorf("room is not active (status=%s)", room.Status) + } + if sportRequiresBuddyCode(input.SportType) && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { + return nil, fmt.Errorf("sport %s requires a buddy code", input.SportType) + } + + // --- 2. Idempotency: reject if an active reservation already exists --- + existing, err := s.reservationRepo.GetLatestByRoomID(ctx, input.RoomID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("check existing reservation: %w", err) + } + if existing != nil { + switch existing.ReservationStatus { + case "scheduled", "submitting", "success": + return nil, fmt.Errorf("room already has an active reservation (status=%s)", existing.ReservationStatus) + } + } + + // --- 3. Verify venue and slot availability on TYYS --- + venueID, venueSiteID, err := s.resolveVenueIDs(ctx, input) + if err != nil { + return nil, err + } + if err := s.checkSlotAvailable(ctx, venueID, venueSiteID, input); err != nil { + return nil, err + } + + // --- 4. Apply TYYS opening-time rule --- + // TYYS only accepts reservations starting at 09:00 CST exactly 2 days before + // the reservation date. Before that moment we create a "scheduled" plan; + // the scheduler flips it to "submitting" once the window opens. + openAt, err := tyysOpenTime(input.ReservationDate) + if err != nil { + return nil, fmt.Errorf("calculate tyys open time: %w", err) + } + + status := "scheduled" + if !time.Now().Before(openAt) { + // Window is already open — mark for immediate scheduler pickup. + status = "submitting" + } + + // --- 5. Persist the reservation record --- + venueIDUint, _ := strconv.ParseUint(venueID, 10, 64) + venueSiteIDUint, _ := strconv.ParseUint(venueSiteID, 10, 64) + venueIDResult := uint(venueIDUint) + venueSiteIDResult := uint(venueSiteIDUint) + + buddyCode := "" + if input.BuddyCode != nil { + buddyCode = *input.BuddyCode + } + spaceName := "" + if input.SpaceName != nil { + spaceName = *input.SpaceName + } + + reservation := &models.RoomReservation{ + RoomID: input.RoomID, + Provider: "tyys", + SportType: input.SportType, + CampusName: input.CampusName, + VenueName: input.VenueName, + ReservationDate: input.ReservationDate, + StartTime: input.StartTime, + EndTime: input.EndTime, + VenueID: &venueIDResult, + VenueSiteID: &venueSiteIDResult, + SpaceID: input.SpaceID, + SpaceName: spaceName, + BuddyCode: buddyCode, + ReservationStatus: status, + ScheduleStatus: "waiting", + ReserveOpenAt: &openAt, + } + if err := s.reservationRepo.Create(ctx, reservation); err != nil { + return nil, fmt.Errorf("create reservation: %w", err) + } + + // --- 6. Mirror status onto the room so the room card reflects it --- + room.ReservationStatus = status + if updateErr := s.roomRepo.Update(ctx, room); updateErr != nil { + // Non-fatal: reservation is already saved; log and continue. + _ = s.logAttempt(ctx, input.RoomID, reservation.ID, "update_room_status", false, updateErr.Error()) + } + + // --- 7. Record a successful plan-creation attempt --- + _ = s.logAttempt(ctx, input.RoomID, reservation.ID, "submit_plan", true, + fmt.Sprintf("reservation created with status=%s openAt=%s", status, openAt.Format(time.RFC3339))) + + return reservation, nil +} + +// tyysOpenTime returns the moment at which TYYS accepts bookings for a given +// reservation date: 09:00 CST exactly 2 calendar days before that date. +func tyysOpenTime(reservationDate string) (time.Time, error) { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.Time{}, fmt.Errorf("load Asia/Shanghai location: %w", err) + } + date, err := time.ParseInLocation("2006-01-02", reservationDate, loc) + if err != nil { + return time.Time{}, fmt.Errorf("parse reservation date %q: %w", reservationDate, err) + } + open := date.AddDate(0, 0, -2) + return time.Date(open.Year(), open.Month(), open.Day(), 9, 0, 0, 0, loc), nil +} + +// logAttempt writes a ReservationAttemptLog record; errors are ignored by callers +// because logging failures must not abort the main flow. +func (s *ReservationService) logAttempt(ctx context.Context, roomID, reservationID uint, stage string, success bool, message string) error { + entry := &models.ReservationAttemptLog{ + RoomID: &roomID, + ReservationID: &reservationID, + Stage: stage, + Success: success, + Message: message, + } + return s.reservationRepo.CreateAttemptLog(ctx, entry) } diff --git a/internal/service/room_service.go b/internal/service/room_service.go index 608795a..02309b8 100644 --- a/internal/service/room_service.go +++ b/internal/service/room_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/rand" + "encoding/hex" "fmt" "time" @@ -118,12 +120,100 @@ func (s *RoomService) GetMyStats(ctx context.Context) (*UserStatsOutput, error) return nil, fmt.Errorf("room service GetMyStats not implemented") } +// GetByID returns the room and its members (with user info preloaded). func (s *RoomService) GetByID(ctx context.Context, id uint) (*models.Room, []models.RoomMember, error) { - return nil, nil, fmt.Errorf("room service GetByID not implemented") -} - + room, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, nil, err + } + members, err := s.repo.GetMembersByRoomID(ctx, id) + if err != nil { + return nil, nil, fmt.Errorf("get members: %w", err) + } + return room, members, nil +} + +// Create creates a new room and adds the current user as the owner member. func (s *RoomService) Create(ctx context.Context, input CreateRoomInput) (*models.Room, error) { - return nil, fmt.Errorf("room service Create not implemented") + owner, err := s.userService.GetCurrent(ctx) + if err != nil { + return nil, fmt.Errorf("get current user: %w", err) + } + + // reservation_status tracks the TYYS booking state; seed it from need_reservation. + reservationStatus := "not_required" + if input.NeedReservation { + reservationStatus = "pending" + } + + inviteCode, err := generateInviteCode() + if err != nil { + return nil, fmt.Errorf("generate invite code: %w", err) + } + + memberLimit := (*int)(nil) + if input.MemberLimit != nil { + v := int(*input.MemberLimit) + memberLimit = &v + } + + room := &models.Room{ + OwnerID: owner.ID, + Name: input.Name, + SportType: input.SportType, + CampusName: input.CampusName, + VenueName: input.VenueName, + Visibility: input.Visibility, + JoinMode: input.JoinMode, + Status: "recruiting", + ReservationStatus: reservationStatus, + ReservationProvider: "tyys", + NeedReservation: input.NeedReservation, + StartTime: input.StartTime, + EndTime: input.EndTime, + MemberLimit: memberLimit, + InviteCode: inviteCode, + } + if input.GenderRule != nil { + room.GenderRule = *input.GenderRule + } + if input.Organization != nil { + room.Organization = *input.Organization + } + if input.LevelDesc != nil { + room.LevelDesc = *input.LevelDesc + } + if input.Description != nil { + room.Description = *input.Description + } + + if err := s.repo.Create(ctx, room); err != nil { + return nil, fmt.Errorf("create room: %w", err) + } + + // Add the creator as the first member with role "owner". + now := time.Now() + ownerMember := &models.RoomMember{ + RoomID: room.ID, + UserID: owner.ID, + Role: "owner", + Status: "joined", + JoinedAt: &now, + } + if err := s.repo.CreateMember(ctx, ownerMember); err != nil { + return nil, fmt.Errorf("add owner as member: %w", err) + } + + return room, nil +} + +// generateInviteCode returns an 8-character random hex string used as the room invite code. +func generateInviteCode() (string, error) { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil } func (s *RoomService) Update(ctx context.Context, roomID uint, input UpdateRoomInput) (*models.Room, error) { diff --git a/internal/service/user_service.go b/internal/service/user_service.go index a748f65..f62c565 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -2,10 +2,13 @@ package service import ( "context" + "errors" "fmt" + "strings" "github.com/QSCTech/SRTP-Backend/internal/repository" "github.com/QSCTech/SRTP-Backend/models" + "gorm.io/gorm" ) type UserService struct { @@ -23,22 +26,108 @@ func NewUserService(repo *repository.UserRepository) *UserService { return &UserService{repo: repo} } +// Create registers a new user with the given authUID. +// Returns the existing user if authUID is already taken. func (s *UserService) Create(ctx context.Context, authUID string) (*models.User, error) { - return nil, fmt.Errorf("user service Create not implemented") + authUID = strings.TrimSpace(authUID) + if authUID == "" { + return nil, fmt.Errorf("auth_uid is required") + } + existing, err := s.repo.GetByAuthUID(ctx, authUID) + if err == nil { + return existing, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("check existing user: %w", err) + } + user := &models.User{ + AuthUID: authUID, + ProfileStatus: "approved", + } + if err := s.repo.Create(ctx, user); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + return user, nil } +// GetByID returns the user with the given primary key. func (s *UserService) GetByID(ctx context.Context, id uint) (*models.User, error) { - return nil, fmt.Errorf("user service GetByID not implemented") + user, err := s.repo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("get user: %w", err) + } + return user, nil } +// GetCurrent returns the acting user for this request. +// Dev fallback: returns the first user in DB until real auth middleware is wired up. func (s *UserService) GetCurrent(ctx context.Context) (*models.User, error) { - return nil, fmt.Errorf("user service GetCurrent not implemented") + if uid, ok := ctx.Value(ctxKeyUserID{}).(uint); ok && uid != 0 { + return s.repo.GetByID(ctx, uid) + } + user, err := s.repo.GetFirst(ctx) + if err != nil { + return nil, fmt.Errorf("no users found") + } + return user, nil } +// UpdateCurrentProfile applies whichever profile fields are non-nil. func (s *UserService) UpdateCurrentProfile(ctx context.Context, input UpdateProfileInput) (*models.User, error) { - return nil, fmt.Errorf("user service UpdateCurrentProfile not implemented") + user, err := s.GetCurrent(ctx) + if err != nil { + return nil, err + } + if input.Nickname != nil { + user.Nickname = *input.Nickname + } + if input.AvatarURL != nil { + user.AvatarURL = *input.AvatarURL + } + if input.Gender != nil { + user.Gender = *input.Gender + } + if input.Bio != nil { + user.Bio = *input.Bio + } + if err := s.repo.Update(ctx, user); err != nil { + return nil, fmt.Errorf("update profile: %w", err) + } + return user, nil } +// LoginOrCreate finds a user by authUID or creates a new one. func (s *UserService) LoginOrCreate(ctx context.Context, authUID, openID string) (*models.User, error) { - return nil, fmt.Errorf("user service LoginOrCreate not implemented") + authUID = strings.TrimSpace(authUID) + if authUID == "" { + return nil, fmt.Errorf("auth_uid is required") + } + user, err := s.repo.GetByAuthUID(ctx, authUID) + if err == nil { + if openID != "" && user.OpenID != openID { + user.OpenID = openID + if updateErr := s.repo.Update(ctx, user); updateErr != nil { + return nil, fmt.Errorf("update open_id: %w", updateErr) + } + } + return user, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("lookup user: %w", err) + } + user = &models.User{ + AuthUID: authUID, + OpenID: openID, + ProfileStatus: "approved", + } + if err := s.repo.Create(ctx, user); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + return user, nil } + +// ctxKeyUserID is the context key used by auth middleware to pass the current user ID. +type ctxKeyUserID struct{} From fa9ce2be1c0062638cd002469854691f936a0c2b Mon Sep 17 00:00:00 2001 From: GP Date: Thu, 23 Apr 2026 18:15:15 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E9=A2=84=E8=A7=88/=E6=8F=90=E4=BA=A4=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=B9=B6=E5=BC=95=E5=85=A5=20SportConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 SlotKey 获取逻辑:timeId 是 TYYS 数据中的父级 map key 而非 slot 对象字段,新增 walkSlotsWithTimeID / walkJSONObjectsWithKey 两个辅助函数正确捕获 timeId - 引入 SportConfig(sport_config.go)替换原 sportRequiresBuddyCode, 集中管理各运动类型的 RequiresBuddyCode 和 MinMemberCount 规则 - Preview / Submit 均新增最低成员数校验(羽毛球/网球 >= 2 人) - 完善 OpenAPI 描述:preview/submit endpoint 说明错误场景、TYYS 开放时间规则、幂等性保证;场馆/时间段字段补充中文注释 Co-Authored-By: Claude Sonnet 4.6 --- api/openapi/openapi.yaml | 43 ++++++++++++-- internal/api/gen/api.gen.go | 70 +++++++++++++++------- internal/service/reservation_service.go | 78 ++++++++++++++++++++----- internal/service/sport_config.go | 34 +++++++++++ 4 files changed, 184 insertions(+), 41 deletions(-) create mode 100644 internal/service/sport_config.go diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 5521b63..2ec7de8 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -564,6 +564,23 @@ paths: post: operationId: previewRoomReservation summary: Preview reservation for a room + description: | + 预约预览接口,在正式提交前校验预约参数的合法性并返回确认信息。 + **不写库、不占用场地**,仅做以下校验: + - 房间存在且状态为 recruiting / full(非 cancelled / finished) + - 房间设置了 need_reservation=true + - 球类前置条件:羽毛球、网球必须提供 buddy_code;最低人数不足时直接拒绝 + - 调用 TYYS 确认指定日期、时间段存在且至少有一个球场可约 + + 返回的 venue_id / venue_site_id 为 TYYS 已解析的场馆 ID,提交时可直接复用, + 避免 submit 再做一次场馆查询。 + + **错误场景**: + - `400 room does not require reservation` — 房间无需预约 + - `400 sport X requires a buddy code` — 未提供同伴码 + - `400 sport X requires at least N members` — 房间人数不足 + - `400 venue not found` — TYYS 找不到对应场馆 + - `400 slot not found / not available` — 该时段不存在或已被占用 parameters: - $ref: '#/components/parameters/RoomIdPath' requestBody: @@ -574,13 +591,13 @@ paths: $ref: '#/components/schemas/ReservationSubmitRequest' responses: '200': - description: Reservation preview + description: 预约参数合法,返回预览信息(包含解析后的 venue_id / venue_site_id) content: application/json: schema: $ref: '#/components/schemas/ReservationPreviewResponse' '400': - description: Invalid request + description: 参数不合法或校验失败 content: application/json: schema: @@ -589,6 +606,24 @@ paths: post: operationId: submitRoomReservation summary: Submit reservation for a room + description: | + 正式提交预约计划,写库并根据 TYYS 开放时间规则决定初始状态。 + **校验逻辑与 preview 相同**(房间状态、同伴码、最低人数、TYYS 时段可用性)。 + + **TYYS 开放时间规则**: + - TYYS 仅在预约日期前 2 天的 09:00 CST 开放预约窗口 + - 当前时间 < 开放时间 → `reservation_status: scheduled`,记录 reserve_open_at + - 当前时间 ≥ 开放时间 → `reservation_status: submitting`,等待调度器立即处理 + + **真正调用 TYYS 的时机**由后端调度器(POST /internal/tasks/reservation-trigger)负责, + 不在本接口内同步完成,因此本接口不受验证码的影响,响应较快。 + + **幂等性**:同一房间已有 scheduled / submitting / success 状态时拒绝重复提交; + failed 状态允许重新提交。 + + **错误场景**: + - `400 room already has an active reservation` — 已有进行中的预约计划 + - 其余同 preview 接口 parameters: - $ref: '#/components/parameters/RoomIdPath' requestBody: @@ -599,13 +634,13 @@ paths: $ref: '#/components/schemas/ReservationSubmitRequest' responses: '200': - description: Reservation submit result + description: 预约计划创建成功,返回落库后的预约记录(含 reservation_status) content: application/json: schema: $ref: '#/components/schemas/ReservationRecordResponse' '400': - description: Invalid request + description: 参数不合法、校验失败或重复提交 content: application/json: schema: diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index db1bd69..9e678e8 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -130,13 +130,22 @@ type ReservationRecordResponse struct { VenueSiteId *int64 `json:"venue_site_id,omitempty"` } -// ReservationSlot defines model for ReservationSlot. +// ReservationSlot 预约时间段 type ReservationSlot struct { - Available bool `json:"available"` - EndTime string `json:"end_time"` - SlotKey string `json:"slot_key"` + // Available 是否可预约 + Available bool `json:"available"` + + // EndTime 结束时间,格式 HH:mm 或 YYYY-MM-DD HH:mm + EndTime string `json:"end_time"` + + // SlotKey 时间段唯一标识,用于提交预约 + SlotKey string `json:"slot_key"` + + // SpaceName 场地名称(如羽毛球场1号场) SpaceName *string `json:"space_name,omitempty"` - StartTime string `json:"start_time"` + + // StartTime 开始时间,格式 HH:mm 或 YYYY-MM-DD HH:mm + StartTime string `json:"start_time"` } // ReservationSlotListResponse defines model for ReservationSlotListResponse. @@ -159,11 +168,16 @@ type ReservationSubmitRequest struct { VenueSiteId *int64 `json:"venue_site_id,omitempty"` } -// ReservationVenue defines model for ReservationVenue. +// ReservationVenue 场馆信息 type ReservationVenue struct { + // CampusName 校区名称。可选值:紫金港校区、华家池校区、玉泉校区、西溪校区 CampusName string `json:"campus_name"` - SportType string `json:"sport_type"` - VenueName string `json:"venue_name"` + + // SportType 球类类型。可选值:羽毛球、健身、游泳、网球 + SportType string `json:"sport_type"` + + // VenueName 场馆名称 + VenueName string `json:"venue_name"` } // ReservationVenueListResponse defines model for ReservationVenueListResponse. @@ -247,7 +261,7 @@ type RoomOwner struct { Nickname string `json:"nickname"` } -// UpdateProfileRequest defines model for UpdateProfileRequest. +// UpdateProfileRequest Update current user profile. Nickname and bio must pass synchronous blocked-word validation before persistence. type UpdateProfileRequest struct { AvatarUrl *string `json:"avatar_url,omitempty"` Bio *string `json:"bio,omitempty"` @@ -272,13 +286,15 @@ type UpdateRoomRequest struct { // User defines model for User. type User struct { - AuthUid string `json:"auth_uid"` - AvatarUrl string `json:"avatar_url"` - Bio string `json:"bio"` - CreatedAt time.Time `json:"created_at"` - Gender string `json:"gender"` - Id int64 `json:"id"` - Nickname string `json:"nickname"` + AuthUid string `json:"auth_uid"` + AvatarUrl string `json:"avatar_url"` + Bio string `json:"bio"` + CreatedAt time.Time `json:"created_at"` + Gender string `json:"gender"` + Id int64 `json:"id"` + Nickname string `json:"nickname"` + + // ProfileStatus Current profile state. This version uses synchronous blocked-word validation instead of manual review workflow. ProfileStatus string `json:"profile_status"` UpdatedAt time.Time `json:"updated_at"` } @@ -328,16 +344,26 @@ type ListMyJoinedRoomsParams struct { // ListReservationSlotsParams defines parameters for ListReservationSlots. type ListReservationSlotsParams struct { - SportType string `form:"sport_type" json:"sport_type"` - CampusName string `form:"campus_name" json:"campus_name"` - VenueName string `form:"venue_name" json:"venue_name"` + // SportType 球类类型。可选值:羽毛球、健身、游泳、网球 + SportType string `form:"sport_type" json:"sport_type"` + + // CampusName 校区名称。可选值:紫金港校区、华家池校区、玉泉校区、西溪校区 + CampusName string `form:"campus_name" json:"campus_name"` + + // VenueName 场馆名称 + VenueName string `form:"venue_name" json:"venue_name"` + + // ReservationDate 预约日期,格式 YYYY-MM-DD ReservationDate openapi_types.Date `form:"reservation_date" json:"reservation_date"` } // ListReservationVenuesParams defines parameters for ListReservationVenues. type ListReservationVenuesParams struct { + // SportType 球类类型,如羽毛球、健身、游泳、网球 SportType *string `form:"sport_type,omitempty" json:"sport_type,omitempty"` - Campus *string `form:"campus,omitempty" json:"campus,omitempty"` + + // Campus 校区名称,如紫金港校区、华家池校区、玉泉校区、西溪校区 + Campus *string `form:"campus,omitempty" json:"campus,omitempty"` } // ListRoomsParams defines parameters for ListRooms. @@ -403,7 +429,7 @@ type ServerInterface interface { // Get current user profile // (GET /me) GetCurrentUser(c *gin.Context) - // Update current user profile + // Update current user profile with synchronous blocked-word validation // (PUT /me/profile) UpdateCurrentUserProfile(c *gin.Context) // List rooms created by current user @@ -418,7 +444,7 @@ type ServerInterface interface { // Service readiness check // (GET /readyz) GetReadyz(c *gin.Context) - // List supported reservation slots + // List available time slots for a venue // (GET /reservations/slots) ListReservationSlots(c *gin.Context, params ListReservationSlotsParams) // List supported reservation venues diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 924e3f0..1e0b8a3 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -206,11 +206,13 @@ func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusNam return nil, fmt.Errorf("get day info: %w", err) } - // Step 3: Parse slots from day info response + // Step 3: Parse slots from day info response. + // Use walkSlotsWithTimeID so the parent map key (which is the TYYS timeId) + // is captured as SlotKey — slot objects themselves do not contain a timeId field. var slots []ReservationSlotItem - walkSlots(dayResp.Data, func(slot map[string]any) { + walkSlotsWithTimeID(dayResp.Data, func(timeID string, slot map[string]any) { item := ReservationSlotItem{ - SlotKey: trimString(slot["timeId"]), + SlotKey: timeID, StartTime: trimString(slot["startDate"]), EndTime: trimString(slot["endDate"]), Available: isSlotAvailable(slot), @@ -252,6 +254,42 @@ func walkSlots(data []byte, visit func(map[string]any)) { }) } +// walkSlotsWithTimeID is like walkSlots but also captures the parent map key, +// which is the TYYS timeId. TYYS stores slot objects as values keyed by timeId +// inside a space object, so the timeId is only visible from the parent's perspective. +func walkSlotsWithTimeID(data []byte, visit func(timeID string, slot map[string]any)) { + var payload any + if err := json.Unmarshal(data, &payload); err != nil { + return + } + walkJSONObjectsWithKey(payload, func(key string, obj map[string]any) { + if _, hasStart := obj["startDate"]; hasStart { + visit(key, obj) + } + }) +} + +// walkJSONObjectsWithKey recursively walks a parsed JSON structure and calls +// visit for each map entry whose value is itself a map, passing the entry key. +// This lets callers see the key under which each object is stored. +func walkJSONObjectsWithKey(value any, visit func(key string, obj map[string]any)) { + switch typed := value.(type) { + case map[string]any: + for k, child := range typed { + if obj, ok := child.(map[string]any); ok { + visit(k, obj) + walkJSONObjectsWithKey(obj, visit) + } else { + walkJSONObjectsWithKey(child, visit) + } + } + case []any: + for _, child := range typed { + walkJSONObjectsWithKey(child, visit) + } + } +} + // Preview validates a proposed reservation against the room state and TYYS availability // without writing anything to the database. The caller uses the returned output to confirm // details before calling Submit. @@ -267,12 +305,23 @@ func (s *ReservationService) Preview(ctx context.Context, input ReservationPrevi if room.Status == "cancelled" || room.Status == "finished" { return nil, fmt.Errorf("room is not active (status=%s)", room.Status) } + sportCfg := getSportConfig(input.SportType) + // TYYS requires a buddy/partner code when booking pair courts (羽毛球, 网球). // Fail early here so the user sees a clear error before any network call. - if sportRequiresBuddyCode(input.SportType) && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { + if sportCfg.RequiresBuddyCode && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { return nil, fmt.Errorf("sport %s requires a buddy code", input.SportType) } + // Pair sports need a minimum number of joined members before reservation makes sense. + memberCount, err := s.roomRepo.CountActiveMembers(ctx, input.RoomID) + if err != nil { + return nil, fmt.Errorf("count active members: %w", err) + } + if int(memberCount) < sportCfg.MinMemberCount { + return nil, fmt.Errorf("sport %s requires at least %d members before reservation (current: %d)", input.SportType, sportCfg.MinMemberCount, memberCount) + } + venueID, venueSiteID, err := s.resolveVenueIDs(ctx, input) if err != nil { return nil, err @@ -307,16 +356,6 @@ func (s *ReservationService) Preview(ctx context.Context, input ReservationPrevi }, nil } -// sportRequiresBuddyCode reports whether the TYYS system requires a partner (buddy) code -// for the given sport type. Pair sports (羽毛球, 网球) must have a buddy code in the -// order form; individual or group sports (健身, 游泳) do not. -func sportRequiresBuddyCode(sport string) bool { - switch strings.TrimSpace(sport) { - case "羽毛球", "网球": - return true - } - return false -} // resolveVenueIDs returns the TYYS venueId and venueSiteId for the requested venue. // If the caller already obtained these IDs (e.g. from ListSlots), they are returned @@ -421,10 +460,19 @@ func (s *ReservationService) Submit(ctx context.Context, input ReservationPrevie if room.Status == "cancelled" || room.Status == "finished" { return nil, fmt.Errorf("room is not active (status=%s)", room.Status) } - if sportRequiresBuddyCode(input.SportType) && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { + sportCfg := getSportConfig(input.SportType) + if sportCfg.RequiresBuddyCode && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { return nil, fmt.Errorf("sport %s requires a buddy code", input.SportType) } + memberCount, err := s.roomRepo.CountActiveMembers(ctx, input.RoomID) + if err != nil { + return nil, fmt.Errorf("count active members: %w", err) + } + if int(memberCount) < sportCfg.MinMemberCount { + return nil, fmt.Errorf("sport %s requires at least %d members before reservation (current: %d)", input.SportType, sportCfg.MinMemberCount, memberCount) + } + // --- 2. Idempotency: reject if an active reservation already exists --- existing, err := s.reservationRepo.GetLatestByRoomID(ctx, input.RoomID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/service/sport_config.go b/internal/service/sport_config.go new file mode 100644 index 0000000..cf79d61 --- /dev/null +++ b/internal/service/sport_config.go @@ -0,0 +1,34 @@ +package service + +import "strings" + +// SportConfig defines TYYS reservation rules for a specific sport type. +// Centralising this avoids scattered switch/if chains across service methods. +type SportConfig struct { + // RequiresBuddyCode is true for pair sports (e.g. 羽毛球, 网球) where TYYS + // requires a partner/buddy code in the order form. + RequiresBuddyCode bool + + // MinMemberCount is the minimum number of joined room members required + // before a reservation plan (preview/submit) is allowed. Pair sports need + // at least 2 players in the room so the booking makes sense. + MinMemberCount int +} + +// sportConfigs maps TYYS sport name to its reservation config. +// Add new sport types here as TYYS expands its offerings. +var sportConfigs = map[string]SportConfig{ + "羽毛球": {RequiresBuddyCode: true, MinMemberCount: 2}, + "网球": {RequiresBuddyCode: true, MinMemberCount: 2}, + "健身": {RequiresBuddyCode: false, MinMemberCount: 1}, + "游泳": {RequiresBuddyCode: false, MinMemberCount: 1}, +} + +// getSportConfig returns the SportConfig for a given sport type name. +// Unknown sports fall back to a safe default (no buddy code, 1 member minimum). +func getSportConfig(sport string) SportConfig { + if cfg, ok := sportConfigs[strings.TrimSpace(sport)]; ok { + return cfg + } + return SportConfig{RequiresBuddyCode: false, MinMemberCount: 1} +} From ca0a280eaa529c8e5e3a50ada3f5863027370d84 Mon Sep 17 00:00:00 2001 From: GP Date: Thu, 23 Apr 2026 17:01:36 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E9=A2=84=E8=A7=88=E3=80=81=E6=8F=90=E4=BA=A4=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=8F=8A=E5=9F=BA=E7=A1=80=20Room/User=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reservation_service: 实现 Preview(校验房间状态、同伴码、TYYS 时间段可用性) - reservation_service: 实现 Submit(落库、TYYS 开放时间规则、幂等性校验、attempt log) - reservation_service: 修复 checkSlotAvailable 多球场场景下的可用性判断逻辑 - room_service: 实现 Create(生成邀请码、写库、添加房主为首位成员) - room_service: 实现 GetByID(返回房间及成员列表) - user_service: 实现 Create / GetByID / GetCurrent / LoginOrCreate / UpdateCurrentProfile Co-Authored-By: Claude Sonnet 4.6 --- internal/service/reservation_service.go | 287 +++++++++++++++++++++++- internal/service/room_service.go | 98 +++++++- internal/service/user_service.go | 99 +++++++- 3 files changed, 473 insertions(+), 11 deletions(-) diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 54b491c..924e3f0 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -3,14 +3,17 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "net/url" "strconv" "strings" + "time" "github.com/QSCTech/SRTP-Backend/internal/repository" "github.com/QSCTech/SRTP-Backend/internal/zjulogin" "github.com/QSCTech/SRTP-Backend/models" + "gorm.io/gorm" ) type ReservationVenueItem struct { @@ -249,10 +252,290 @@ func walkSlots(data []byte, visit func(map[string]any)) { }) } +// Preview validates a proposed reservation against the room state and TYYS availability +// without writing anything to the database. The caller uses the returned output to confirm +// details before calling Submit. func (s *ReservationService) Preview(ctx context.Context, input ReservationPreviewInput) (*ReservationPreviewOutput, error) { - return nil, fmt.Errorf("reservation service Preview not implemented") + room, err := s.roomRepo.GetByID(ctx, input.RoomID) + if err != nil { + return nil, fmt.Errorf("room not found: %w", err) + } + if !room.NeedReservation { + return nil, fmt.Errorf("room does not require reservation") + } + // Only active rooms (recruiting / full) may enter the reservation flow. + if room.Status == "cancelled" || room.Status == "finished" { + return nil, fmt.Errorf("room is not active (status=%s)", room.Status) + } + // TYYS requires a buddy/partner code when booking pair courts (羽毛球, 网球). + // Fail early here so the user sees a clear error before any network call. + if sportRequiresBuddyCode(input.SportType) && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { + return nil, fmt.Errorf("sport %s requires a buddy code", input.SportType) + } + + venueID, venueSiteID, err := s.resolveVenueIDs(ctx, input) + if err != nil { + return nil, err + } + + if err := s.checkSlotAvailable(ctx, venueID, venueSiteID, input); err != nil { + return nil, err + } + + // TYYS returns IDs as strings; convert back to uint so the response carries + // the resolved IDs that Submit can reuse without a second venue lookup. + venueIDUint, _ := strconv.ParseUint(venueID, 10, 64) + venueSiteIDUint, _ := strconv.ParseUint(venueSiteID, 10, 64) + venueIDResult := uint(venueIDUint) + venueSiteIDResult := uint(venueSiteIDUint) + + return &ReservationPreviewOutput{ + RoomID: input.RoomID, + Provider: "tyys", + ReservationStatus: "pending", // preview does not submit; status stays pending until Submit succeeds + SportType: input.SportType, + CampusName: input.CampusName, + VenueName: input.VenueName, + ReservationDate: input.ReservationDate, + StartTime: input.StartTime, + EndTime: input.EndTime, + BuddyCode: input.BuddyCode, + VenueID: &venueIDResult, + VenueSiteID: &venueSiteIDResult, + SpaceID: input.SpaceID, + SpaceName: input.SpaceName, + }, nil +} + +// sportRequiresBuddyCode reports whether the TYYS system requires a partner (buddy) code +// for the given sport type. Pair sports (羽毛球, 网球) must have a buddy code in the +// order form; individual or group sports (健身, 游泳) do not. +func sportRequiresBuddyCode(sport string) bool { + switch strings.TrimSpace(sport) { + case "羽毛球", "网球": + return true + } + return false +} + +// resolveVenueIDs returns the TYYS venueId and venueSiteId for the requested venue. +// If the caller already obtained these IDs (e.g. from ListSlots), they are returned +// directly to avoid an extra round-trip to the TYYS venue-info API. +func (s *ReservationService) resolveVenueIDs(ctx context.Context, input ReservationPreviewInput) (venueID, venueSiteID string, err error) { + if input.VenueID != nil && input.VenueSiteID != nil { + return strconv.FormatUint(uint64(*input.VenueID), 10), strconv.FormatUint(uint64(*input.VenueSiteID), 10), nil + } + + venueResp, err := s.tyys.VenueInfo(ctx, 0) + if err != nil { + return "", "", fmt.Errorf("get venue info: %w", err) + } + + // walkVenues has no early-exit mechanism; guard with venueID != "" to stop processing + // after the first match and avoid overwriting with a later duplicate entry. + walkVenues(venueResp.Data, func(obj map[string]any) { + if venueID != "" { + return + } + if !textMatches(trimString(obj["sportName"]), input.SportType) { + return + } + if !textMatches(trimString(obj["campusName"]), input.CampusName) { + return + } + if !textMatches(trimString(obj["venueName"]), input.VenueName) { + return + } + venueID = trimString(obj["venueId"]) + venueSiteID = trimString(obj["id"]) + }) + + if venueID == "" || venueSiteID == "" { + return "", "", fmt.Errorf("venue not found for sport=%s campus=%s venue=%s", input.SportType, input.CampusName, input.VenueName) + } + return venueID, venueSiteID, nil +} + +// checkSlotAvailable queries TYYS day info and confirms the specific start/end time slot +// exists and is free. The TYYS API stores slot times as "YYYY-MM-DD HH:mm", so we +// concatenate reservationDate + " " + time before comparing against startDate/endDate. +func (s *ReservationService) checkSlotAvailable(ctx context.Context, venueID, venueSiteID string, input ReservationPreviewInput) error { + params := url.Values{} + params.Set("venueId", venueID) + params.Set("venueSiteId", venueSiteID) + params.Set("siteId", venueSiteID) // TYYS day-info API accepts both siteId and venueSiteId; send both for compatibility + params.Set("date", input.ReservationDate) + params.Set("reservationDate", input.ReservationDate) + params.Set("searchDate", input.ReservationDate) + + dayResp, err := s.tyys.ReservationDayInfo(ctx, params) + if err != nil { + return fmt.Errorf("get day info: %w", err) + } + + // TYYS slot timestamps are formatted as "YYYY-MM-DD HH:mm". + wantStart := input.ReservationDate + " " + input.StartTime + wantEnd := input.ReservationDate + " " + input.EndTime + found := false + available := false + walkSlots(dayResp.Data, func(slot map[string]any) { + // A venue has multiple courts; the same time window appears once per court. + // Stop early once we find an available court — we don't need all of them. + if available { + return + } + if trimString(slot["startDate"]) == wantStart && trimString(slot["endDate"]) == wantEnd { + found = true + if isSlotAvailable(slot) { + available = true + } + } + }) + + if !found { + return fmt.Errorf("slot not found for %s %s-%s", input.ReservationDate, input.StartTime, input.EndTime) + } + if !available { + return fmt.Errorf("slot %s %s-%s is not available", input.ReservationDate, input.StartTime, input.EndTime) + } + return nil } +// Submit validates the reservation request, writes a RoomReservation record, and +// determines its initial status based on the TYYS opening-time rule: +// - Before the window: status="scheduled", ReserveOpenAt records when to trigger +// - After the window: status="submitting", ready for the scheduler to pick up +// +// The actual TYYS call (which requires captcha solving) is NOT made here; +// it is delegated to POST /internal/tasks/reservation-trigger so the HTTP +// response stays fast and captcha complexity lives in the scheduler. func (s *ReservationService) Submit(ctx context.Context, input ReservationPreviewInput) (*models.RoomReservation, error) { - return nil, fmt.Errorf("reservation service Submit not implemented") + // --- 1. Validate room state (same gates as Preview) --- + room, err := s.roomRepo.GetByID(ctx, input.RoomID) + if err != nil { + return nil, fmt.Errorf("room not found: %w", err) + } + if !room.NeedReservation { + return nil, fmt.Errorf("room does not require reservation") + } + if room.Status == "cancelled" || room.Status == "finished" { + return nil, fmt.Errorf("room is not active (status=%s)", room.Status) + } + if sportRequiresBuddyCode(input.SportType) && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { + return nil, fmt.Errorf("sport %s requires a buddy code", input.SportType) + } + + // --- 2. Idempotency: reject if an active reservation already exists --- + existing, err := s.reservationRepo.GetLatestByRoomID(ctx, input.RoomID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("check existing reservation: %w", err) + } + if existing != nil { + switch existing.ReservationStatus { + case "scheduled", "submitting", "success": + return nil, fmt.Errorf("room already has an active reservation (status=%s)", existing.ReservationStatus) + } + } + + // --- 3. Verify venue and slot availability on TYYS --- + venueID, venueSiteID, err := s.resolveVenueIDs(ctx, input) + if err != nil { + return nil, err + } + if err := s.checkSlotAvailable(ctx, venueID, venueSiteID, input); err != nil { + return nil, err + } + + // --- 4. Apply TYYS opening-time rule --- + // TYYS only accepts reservations starting at 09:00 CST exactly 2 days before + // the reservation date. Before that moment we create a "scheduled" plan; + // the scheduler flips it to "submitting" once the window opens. + openAt, err := tyysOpenTime(input.ReservationDate) + if err != nil { + return nil, fmt.Errorf("calculate tyys open time: %w", err) + } + + status := "scheduled" + if !time.Now().Before(openAt) { + // Window is already open — mark for immediate scheduler pickup. + status = "submitting" + } + + // --- 5. Persist the reservation record --- + venueIDUint, _ := strconv.ParseUint(venueID, 10, 64) + venueSiteIDUint, _ := strconv.ParseUint(venueSiteID, 10, 64) + venueIDResult := uint(venueIDUint) + venueSiteIDResult := uint(venueSiteIDUint) + + buddyCode := "" + if input.BuddyCode != nil { + buddyCode = *input.BuddyCode + } + spaceName := "" + if input.SpaceName != nil { + spaceName = *input.SpaceName + } + + reservation := &models.RoomReservation{ + RoomID: input.RoomID, + Provider: "tyys", + SportType: input.SportType, + CampusName: input.CampusName, + VenueName: input.VenueName, + ReservationDate: input.ReservationDate, + StartTime: input.StartTime, + EndTime: input.EndTime, + VenueID: &venueIDResult, + VenueSiteID: &venueSiteIDResult, + SpaceID: input.SpaceID, + SpaceName: spaceName, + BuddyCode: buddyCode, + ReservationStatus: status, + ScheduleStatus: "waiting", + ReserveOpenAt: &openAt, + } + if err := s.reservationRepo.Create(ctx, reservation); err != nil { + return nil, fmt.Errorf("create reservation: %w", err) + } + + // --- 6. Mirror status onto the room so the room card reflects it --- + room.ReservationStatus = status + if updateErr := s.roomRepo.Update(ctx, room); updateErr != nil { + // Non-fatal: reservation is already saved; log and continue. + _ = s.logAttempt(ctx, input.RoomID, reservation.ID, "update_room_status", false, updateErr.Error()) + } + + // --- 7. Record a successful plan-creation attempt --- + _ = s.logAttempt(ctx, input.RoomID, reservation.ID, "submit_plan", true, + fmt.Sprintf("reservation created with status=%s openAt=%s", status, openAt.Format(time.RFC3339))) + + return reservation, nil +} + +// tyysOpenTime returns the moment at which TYYS accepts bookings for a given +// reservation date: 09:00 CST exactly 2 calendar days before that date. +func tyysOpenTime(reservationDate string) (time.Time, error) { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.Time{}, fmt.Errorf("load Asia/Shanghai location: %w", err) + } + date, err := time.ParseInLocation("2006-01-02", reservationDate, loc) + if err != nil { + return time.Time{}, fmt.Errorf("parse reservation date %q: %w", reservationDate, err) + } + open := date.AddDate(0, 0, -2) + return time.Date(open.Year(), open.Month(), open.Day(), 9, 0, 0, 0, loc), nil +} + +// logAttempt writes a ReservationAttemptLog record; errors are ignored by callers +// because logging failures must not abort the main flow. +func (s *ReservationService) logAttempt(ctx context.Context, roomID, reservationID uint, stage string, success bool, message string) error { + entry := &models.ReservationAttemptLog{ + RoomID: &roomID, + ReservationID: &reservationID, + Stage: stage, + Success: success, + Message: message, + } + return s.reservationRepo.CreateAttemptLog(ctx, entry) } diff --git a/internal/service/room_service.go b/internal/service/room_service.go index 608795a..02309b8 100644 --- a/internal/service/room_service.go +++ b/internal/service/room_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/rand" + "encoding/hex" "fmt" "time" @@ -118,12 +120,100 @@ func (s *RoomService) GetMyStats(ctx context.Context) (*UserStatsOutput, error) return nil, fmt.Errorf("room service GetMyStats not implemented") } +// GetByID returns the room and its members (with user info preloaded). func (s *RoomService) GetByID(ctx context.Context, id uint) (*models.Room, []models.RoomMember, error) { - return nil, nil, fmt.Errorf("room service GetByID not implemented") -} - + room, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, nil, err + } + members, err := s.repo.GetMembersByRoomID(ctx, id) + if err != nil { + return nil, nil, fmt.Errorf("get members: %w", err) + } + return room, members, nil +} + +// Create creates a new room and adds the current user as the owner member. func (s *RoomService) Create(ctx context.Context, input CreateRoomInput) (*models.Room, error) { - return nil, fmt.Errorf("room service Create not implemented") + owner, err := s.userService.GetCurrent(ctx) + if err != nil { + return nil, fmt.Errorf("get current user: %w", err) + } + + // reservation_status tracks the TYYS booking state; seed it from need_reservation. + reservationStatus := "not_required" + if input.NeedReservation { + reservationStatus = "pending" + } + + inviteCode, err := generateInviteCode() + if err != nil { + return nil, fmt.Errorf("generate invite code: %w", err) + } + + memberLimit := (*int)(nil) + if input.MemberLimit != nil { + v := int(*input.MemberLimit) + memberLimit = &v + } + + room := &models.Room{ + OwnerID: owner.ID, + Name: input.Name, + SportType: input.SportType, + CampusName: input.CampusName, + VenueName: input.VenueName, + Visibility: input.Visibility, + JoinMode: input.JoinMode, + Status: "recruiting", + ReservationStatus: reservationStatus, + ReservationProvider: "tyys", + NeedReservation: input.NeedReservation, + StartTime: input.StartTime, + EndTime: input.EndTime, + MemberLimit: memberLimit, + InviteCode: inviteCode, + } + if input.GenderRule != nil { + room.GenderRule = *input.GenderRule + } + if input.Organization != nil { + room.Organization = *input.Organization + } + if input.LevelDesc != nil { + room.LevelDesc = *input.LevelDesc + } + if input.Description != nil { + room.Description = *input.Description + } + + if err := s.repo.Create(ctx, room); err != nil { + return nil, fmt.Errorf("create room: %w", err) + } + + // Add the creator as the first member with role "owner". + now := time.Now() + ownerMember := &models.RoomMember{ + RoomID: room.ID, + UserID: owner.ID, + Role: "owner", + Status: "joined", + JoinedAt: &now, + } + if err := s.repo.CreateMember(ctx, ownerMember); err != nil { + return nil, fmt.Errorf("add owner as member: %w", err) + } + + return room, nil +} + +// generateInviteCode returns an 8-character random hex string used as the room invite code. +func generateInviteCode() (string, error) { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil } func (s *RoomService) Update(ctx context.Context, roomID uint, input UpdateRoomInput) (*models.Room, error) { diff --git a/internal/service/user_service.go b/internal/service/user_service.go index a748f65..f62c565 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -2,10 +2,13 @@ package service import ( "context" + "errors" "fmt" + "strings" "github.com/QSCTech/SRTP-Backend/internal/repository" "github.com/QSCTech/SRTP-Backend/models" + "gorm.io/gorm" ) type UserService struct { @@ -23,22 +26,108 @@ func NewUserService(repo *repository.UserRepository) *UserService { return &UserService{repo: repo} } +// Create registers a new user with the given authUID. +// Returns the existing user if authUID is already taken. func (s *UserService) Create(ctx context.Context, authUID string) (*models.User, error) { - return nil, fmt.Errorf("user service Create not implemented") + authUID = strings.TrimSpace(authUID) + if authUID == "" { + return nil, fmt.Errorf("auth_uid is required") + } + existing, err := s.repo.GetByAuthUID(ctx, authUID) + if err == nil { + return existing, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("check existing user: %w", err) + } + user := &models.User{ + AuthUID: authUID, + ProfileStatus: "approved", + } + if err := s.repo.Create(ctx, user); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + return user, nil } +// GetByID returns the user with the given primary key. func (s *UserService) GetByID(ctx context.Context, id uint) (*models.User, error) { - return nil, fmt.Errorf("user service GetByID not implemented") + user, err := s.repo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("get user: %w", err) + } + return user, nil } +// GetCurrent returns the acting user for this request. +// Dev fallback: returns the first user in DB until real auth middleware is wired up. func (s *UserService) GetCurrent(ctx context.Context) (*models.User, error) { - return nil, fmt.Errorf("user service GetCurrent not implemented") + if uid, ok := ctx.Value(ctxKeyUserID{}).(uint); ok && uid != 0 { + return s.repo.GetByID(ctx, uid) + } + user, err := s.repo.GetFirst(ctx) + if err != nil { + return nil, fmt.Errorf("no users found") + } + return user, nil } +// UpdateCurrentProfile applies whichever profile fields are non-nil. func (s *UserService) UpdateCurrentProfile(ctx context.Context, input UpdateProfileInput) (*models.User, error) { - return nil, fmt.Errorf("user service UpdateCurrentProfile not implemented") + user, err := s.GetCurrent(ctx) + if err != nil { + return nil, err + } + if input.Nickname != nil { + user.Nickname = *input.Nickname + } + if input.AvatarURL != nil { + user.AvatarURL = *input.AvatarURL + } + if input.Gender != nil { + user.Gender = *input.Gender + } + if input.Bio != nil { + user.Bio = *input.Bio + } + if err := s.repo.Update(ctx, user); err != nil { + return nil, fmt.Errorf("update profile: %w", err) + } + return user, nil } +// LoginOrCreate finds a user by authUID or creates a new one. func (s *UserService) LoginOrCreate(ctx context.Context, authUID, openID string) (*models.User, error) { - return nil, fmt.Errorf("user service LoginOrCreate not implemented") + authUID = strings.TrimSpace(authUID) + if authUID == "" { + return nil, fmt.Errorf("auth_uid is required") + } + user, err := s.repo.GetByAuthUID(ctx, authUID) + if err == nil { + if openID != "" && user.OpenID != openID { + user.OpenID = openID + if updateErr := s.repo.Update(ctx, user); updateErr != nil { + return nil, fmt.Errorf("update open_id: %w", updateErr) + } + } + return user, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("lookup user: %w", err) + } + user = &models.User{ + AuthUID: authUID, + OpenID: openID, + ProfileStatus: "approved", + } + if err := s.repo.Create(ctx, user); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + return user, nil } + +// ctxKeyUserID is the context key used by auth middleware to pass the current user ID. +type ctxKeyUserID struct{} From 059927c68ffb495da487b0f51ca4f6565fcfd99c Mon Sep 17 00:00:00 2001 From: GP Date: Thu, 23 Apr 2026 18:15:15 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=A2=84?= =?UTF-8?q?=E7=BA=A6=E9=A2=84=E8=A7=88/=E6=8F=90=E4=BA=A4=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=B9=B6=E5=BC=95=E5=85=A5=20SportConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 SlotKey 获取逻辑:timeId 是 TYYS 数据中的父级 map key 而非 slot 对象字段,新增 walkSlotsWithTimeID / walkJSONObjectsWithKey 两个辅助函数正确捕获 timeId - 引入 SportConfig(sport_config.go)替换原 sportRequiresBuddyCode, 集中管理各运动类型的 RequiresBuddyCode 和 MinMemberCount 规则 - Preview / Submit 均新增最低成员数校验(羽毛球/网球 >= 2 人) - 完善 OpenAPI 描述:preview/submit endpoint 说明错误场景、TYYS 开放时间规则、幂等性保证;场馆/时间段字段补充中文注释 Co-Authored-By: Claude Sonnet 4.6 --- api/openapi/openapi.yaml | 43 ++++++++++++-- internal/api/gen/api.gen.go | 70 +++++++++++++++------- internal/service/reservation_service.go | 78 ++++++++++++++++++++----- internal/service/sport_config.go | 34 +++++++++++ 4 files changed, 184 insertions(+), 41 deletions(-) create mode 100644 internal/service/sport_config.go diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 5521b63..2ec7de8 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -564,6 +564,23 @@ paths: post: operationId: previewRoomReservation summary: Preview reservation for a room + description: | + 预约预览接口,在正式提交前校验预约参数的合法性并返回确认信息。 + **不写库、不占用场地**,仅做以下校验: + - 房间存在且状态为 recruiting / full(非 cancelled / finished) + - 房间设置了 need_reservation=true + - 球类前置条件:羽毛球、网球必须提供 buddy_code;最低人数不足时直接拒绝 + - 调用 TYYS 确认指定日期、时间段存在且至少有一个球场可约 + + 返回的 venue_id / venue_site_id 为 TYYS 已解析的场馆 ID,提交时可直接复用, + 避免 submit 再做一次场馆查询。 + + **错误场景**: + - `400 room does not require reservation` — 房间无需预约 + - `400 sport X requires a buddy code` — 未提供同伴码 + - `400 sport X requires at least N members` — 房间人数不足 + - `400 venue not found` — TYYS 找不到对应场馆 + - `400 slot not found / not available` — 该时段不存在或已被占用 parameters: - $ref: '#/components/parameters/RoomIdPath' requestBody: @@ -574,13 +591,13 @@ paths: $ref: '#/components/schemas/ReservationSubmitRequest' responses: '200': - description: Reservation preview + description: 预约参数合法,返回预览信息(包含解析后的 venue_id / venue_site_id) content: application/json: schema: $ref: '#/components/schemas/ReservationPreviewResponse' '400': - description: Invalid request + description: 参数不合法或校验失败 content: application/json: schema: @@ -589,6 +606,24 @@ paths: post: operationId: submitRoomReservation summary: Submit reservation for a room + description: | + 正式提交预约计划,写库并根据 TYYS 开放时间规则决定初始状态。 + **校验逻辑与 preview 相同**(房间状态、同伴码、最低人数、TYYS 时段可用性)。 + + **TYYS 开放时间规则**: + - TYYS 仅在预约日期前 2 天的 09:00 CST 开放预约窗口 + - 当前时间 < 开放时间 → `reservation_status: scheduled`,记录 reserve_open_at + - 当前时间 ≥ 开放时间 → `reservation_status: submitting`,等待调度器立即处理 + + **真正调用 TYYS 的时机**由后端调度器(POST /internal/tasks/reservation-trigger)负责, + 不在本接口内同步完成,因此本接口不受验证码的影响,响应较快。 + + **幂等性**:同一房间已有 scheduled / submitting / success 状态时拒绝重复提交; + failed 状态允许重新提交。 + + **错误场景**: + - `400 room already has an active reservation` — 已有进行中的预约计划 + - 其余同 preview 接口 parameters: - $ref: '#/components/parameters/RoomIdPath' requestBody: @@ -599,13 +634,13 @@ paths: $ref: '#/components/schemas/ReservationSubmitRequest' responses: '200': - description: Reservation submit result + description: 预约计划创建成功,返回落库后的预约记录(含 reservation_status) content: application/json: schema: $ref: '#/components/schemas/ReservationRecordResponse' '400': - description: Invalid request + description: 参数不合法、校验失败或重复提交 content: application/json: schema: diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index db1bd69..9e678e8 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -130,13 +130,22 @@ type ReservationRecordResponse struct { VenueSiteId *int64 `json:"venue_site_id,omitempty"` } -// ReservationSlot defines model for ReservationSlot. +// ReservationSlot 预约时间段 type ReservationSlot struct { - Available bool `json:"available"` - EndTime string `json:"end_time"` - SlotKey string `json:"slot_key"` + // Available 是否可预约 + Available bool `json:"available"` + + // EndTime 结束时间,格式 HH:mm 或 YYYY-MM-DD HH:mm + EndTime string `json:"end_time"` + + // SlotKey 时间段唯一标识,用于提交预约 + SlotKey string `json:"slot_key"` + + // SpaceName 场地名称(如羽毛球场1号场) SpaceName *string `json:"space_name,omitempty"` - StartTime string `json:"start_time"` + + // StartTime 开始时间,格式 HH:mm 或 YYYY-MM-DD HH:mm + StartTime string `json:"start_time"` } // ReservationSlotListResponse defines model for ReservationSlotListResponse. @@ -159,11 +168,16 @@ type ReservationSubmitRequest struct { VenueSiteId *int64 `json:"venue_site_id,omitempty"` } -// ReservationVenue defines model for ReservationVenue. +// ReservationVenue 场馆信息 type ReservationVenue struct { + // CampusName 校区名称。可选值:紫金港校区、华家池校区、玉泉校区、西溪校区 CampusName string `json:"campus_name"` - SportType string `json:"sport_type"` - VenueName string `json:"venue_name"` + + // SportType 球类类型。可选值:羽毛球、健身、游泳、网球 + SportType string `json:"sport_type"` + + // VenueName 场馆名称 + VenueName string `json:"venue_name"` } // ReservationVenueListResponse defines model for ReservationVenueListResponse. @@ -247,7 +261,7 @@ type RoomOwner struct { Nickname string `json:"nickname"` } -// UpdateProfileRequest defines model for UpdateProfileRequest. +// UpdateProfileRequest Update current user profile. Nickname and bio must pass synchronous blocked-word validation before persistence. type UpdateProfileRequest struct { AvatarUrl *string `json:"avatar_url,omitempty"` Bio *string `json:"bio,omitempty"` @@ -272,13 +286,15 @@ type UpdateRoomRequest struct { // User defines model for User. type User struct { - AuthUid string `json:"auth_uid"` - AvatarUrl string `json:"avatar_url"` - Bio string `json:"bio"` - CreatedAt time.Time `json:"created_at"` - Gender string `json:"gender"` - Id int64 `json:"id"` - Nickname string `json:"nickname"` + AuthUid string `json:"auth_uid"` + AvatarUrl string `json:"avatar_url"` + Bio string `json:"bio"` + CreatedAt time.Time `json:"created_at"` + Gender string `json:"gender"` + Id int64 `json:"id"` + Nickname string `json:"nickname"` + + // ProfileStatus Current profile state. This version uses synchronous blocked-word validation instead of manual review workflow. ProfileStatus string `json:"profile_status"` UpdatedAt time.Time `json:"updated_at"` } @@ -328,16 +344,26 @@ type ListMyJoinedRoomsParams struct { // ListReservationSlotsParams defines parameters for ListReservationSlots. type ListReservationSlotsParams struct { - SportType string `form:"sport_type" json:"sport_type"` - CampusName string `form:"campus_name" json:"campus_name"` - VenueName string `form:"venue_name" json:"venue_name"` + // SportType 球类类型。可选值:羽毛球、健身、游泳、网球 + SportType string `form:"sport_type" json:"sport_type"` + + // CampusName 校区名称。可选值:紫金港校区、华家池校区、玉泉校区、西溪校区 + CampusName string `form:"campus_name" json:"campus_name"` + + // VenueName 场馆名称 + VenueName string `form:"venue_name" json:"venue_name"` + + // ReservationDate 预约日期,格式 YYYY-MM-DD ReservationDate openapi_types.Date `form:"reservation_date" json:"reservation_date"` } // ListReservationVenuesParams defines parameters for ListReservationVenues. type ListReservationVenuesParams struct { + // SportType 球类类型,如羽毛球、健身、游泳、网球 SportType *string `form:"sport_type,omitempty" json:"sport_type,omitempty"` - Campus *string `form:"campus,omitempty" json:"campus,omitempty"` + + // Campus 校区名称,如紫金港校区、华家池校区、玉泉校区、西溪校区 + Campus *string `form:"campus,omitempty" json:"campus,omitempty"` } // ListRoomsParams defines parameters for ListRooms. @@ -403,7 +429,7 @@ type ServerInterface interface { // Get current user profile // (GET /me) GetCurrentUser(c *gin.Context) - // Update current user profile + // Update current user profile with synchronous blocked-word validation // (PUT /me/profile) UpdateCurrentUserProfile(c *gin.Context) // List rooms created by current user @@ -418,7 +444,7 @@ type ServerInterface interface { // Service readiness check // (GET /readyz) GetReadyz(c *gin.Context) - // List supported reservation slots + // List available time slots for a venue // (GET /reservations/slots) ListReservationSlots(c *gin.Context, params ListReservationSlotsParams) // List supported reservation venues diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 924e3f0..1e0b8a3 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -206,11 +206,13 @@ func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusNam return nil, fmt.Errorf("get day info: %w", err) } - // Step 3: Parse slots from day info response + // Step 3: Parse slots from day info response. + // Use walkSlotsWithTimeID so the parent map key (which is the TYYS timeId) + // is captured as SlotKey — slot objects themselves do not contain a timeId field. var slots []ReservationSlotItem - walkSlots(dayResp.Data, func(slot map[string]any) { + walkSlotsWithTimeID(dayResp.Data, func(timeID string, slot map[string]any) { item := ReservationSlotItem{ - SlotKey: trimString(slot["timeId"]), + SlotKey: timeID, StartTime: trimString(slot["startDate"]), EndTime: trimString(slot["endDate"]), Available: isSlotAvailable(slot), @@ -252,6 +254,42 @@ func walkSlots(data []byte, visit func(map[string]any)) { }) } +// walkSlotsWithTimeID is like walkSlots but also captures the parent map key, +// which is the TYYS timeId. TYYS stores slot objects as values keyed by timeId +// inside a space object, so the timeId is only visible from the parent's perspective. +func walkSlotsWithTimeID(data []byte, visit func(timeID string, slot map[string]any)) { + var payload any + if err := json.Unmarshal(data, &payload); err != nil { + return + } + walkJSONObjectsWithKey(payload, func(key string, obj map[string]any) { + if _, hasStart := obj["startDate"]; hasStart { + visit(key, obj) + } + }) +} + +// walkJSONObjectsWithKey recursively walks a parsed JSON structure and calls +// visit for each map entry whose value is itself a map, passing the entry key. +// This lets callers see the key under which each object is stored. +func walkJSONObjectsWithKey(value any, visit func(key string, obj map[string]any)) { + switch typed := value.(type) { + case map[string]any: + for k, child := range typed { + if obj, ok := child.(map[string]any); ok { + visit(k, obj) + walkJSONObjectsWithKey(obj, visit) + } else { + walkJSONObjectsWithKey(child, visit) + } + } + case []any: + for _, child := range typed { + walkJSONObjectsWithKey(child, visit) + } + } +} + // Preview validates a proposed reservation against the room state and TYYS availability // without writing anything to the database. The caller uses the returned output to confirm // details before calling Submit. @@ -267,12 +305,23 @@ func (s *ReservationService) Preview(ctx context.Context, input ReservationPrevi if room.Status == "cancelled" || room.Status == "finished" { return nil, fmt.Errorf("room is not active (status=%s)", room.Status) } + sportCfg := getSportConfig(input.SportType) + // TYYS requires a buddy/partner code when booking pair courts (羽毛球, 网球). // Fail early here so the user sees a clear error before any network call. - if sportRequiresBuddyCode(input.SportType) && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { + if sportCfg.RequiresBuddyCode && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { return nil, fmt.Errorf("sport %s requires a buddy code", input.SportType) } + // Pair sports need a minimum number of joined members before reservation makes sense. + memberCount, err := s.roomRepo.CountActiveMembers(ctx, input.RoomID) + if err != nil { + return nil, fmt.Errorf("count active members: %w", err) + } + if int(memberCount) < sportCfg.MinMemberCount { + return nil, fmt.Errorf("sport %s requires at least %d members before reservation (current: %d)", input.SportType, sportCfg.MinMemberCount, memberCount) + } + venueID, venueSiteID, err := s.resolveVenueIDs(ctx, input) if err != nil { return nil, err @@ -307,16 +356,6 @@ func (s *ReservationService) Preview(ctx context.Context, input ReservationPrevi }, nil } -// sportRequiresBuddyCode reports whether the TYYS system requires a partner (buddy) code -// for the given sport type. Pair sports (羽毛球, 网球) must have a buddy code in the -// order form; individual or group sports (健身, 游泳) do not. -func sportRequiresBuddyCode(sport string) bool { - switch strings.TrimSpace(sport) { - case "羽毛球", "网球": - return true - } - return false -} // resolveVenueIDs returns the TYYS venueId and venueSiteId for the requested venue. // If the caller already obtained these IDs (e.g. from ListSlots), they are returned @@ -421,10 +460,19 @@ func (s *ReservationService) Submit(ctx context.Context, input ReservationPrevie if room.Status == "cancelled" || room.Status == "finished" { return nil, fmt.Errorf("room is not active (status=%s)", room.Status) } - if sportRequiresBuddyCode(input.SportType) && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { + sportCfg := getSportConfig(input.SportType) + if sportCfg.RequiresBuddyCode && (input.BuddyCode == nil || strings.TrimSpace(*input.BuddyCode) == "") { return nil, fmt.Errorf("sport %s requires a buddy code", input.SportType) } + memberCount, err := s.roomRepo.CountActiveMembers(ctx, input.RoomID) + if err != nil { + return nil, fmt.Errorf("count active members: %w", err) + } + if int(memberCount) < sportCfg.MinMemberCount { + return nil, fmt.Errorf("sport %s requires at least %d members before reservation (current: %d)", input.SportType, sportCfg.MinMemberCount, memberCount) + } + // --- 2. Idempotency: reject if an active reservation already exists --- existing, err := s.reservationRepo.GetLatestByRoomID(ctx, input.RoomID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/service/sport_config.go b/internal/service/sport_config.go new file mode 100644 index 0000000..cf79d61 --- /dev/null +++ b/internal/service/sport_config.go @@ -0,0 +1,34 @@ +package service + +import "strings" + +// SportConfig defines TYYS reservation rules for a specific sport type. +// Centralising this avoids scattered switch/if chains across service methods. +type SportConfig struct { + // RequiresBuddyCode is true for pair sports (e.g. 羽毛球, 网球) where TYYS + // requires a partner/buddy code in the order form. + RequiresBuddyCode bool + + // MinMemberCount is the minimum number of joined room members required + // before a reservation plan (preview/submit) is allowed. Pair sports need + // at least 2 players in the room so the booking makes sense. + MinMemberCount int +} + +// sportConfigs maps TYYS sport name to its reservation config. +// Add new sport types here as TYYS expands its offerings. +var sportConfigs = map[string]SportConfig{ + "羽毛球": {RequiresBuddyCode: true, MinMemberCount: 2}, + "网球": {RequiresBuddyCode: true, MinMemberCount: 2}, + "健身": {RequiresBuddyCode: false, MinMemberCount: 1}, + "游泳": {RequiresBuddyCode: false, MinMemberCount: 1}, +} + +// getSportConfig returns the SportConfig for a given sport type name. +// Unknown sports fall back to a safe default (no buddy code, 1 member minimum). +func getSportConfig(sport string) SportConfig { + if cfg, ok := sportConfigs[strings.TrimSpace(sport)]; ok { + return cfg + } + return SportConfig{RequiresBuddyCode: false, MinMemberCount: 1} +}