From 347555fab08a5af4ca6106711b1e5575250e4931 Mon Sep 17 00:00:00 2001 From: GP Date: Mon, 20 Apr 2026 12:56:34 +0800 Subject: [PATCH 1/3] =?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 1fc07cb09b431bfc03b794b4f66062780b6d5287 Mon Sep 17 00:00:00 2001 From: GP Date: Sun, 26 Apr 2026 13:11:13 +0800 Subject: [PATCH 2/3] feat(reservation): refactor slot schema and enrich TYYS v2 fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 ReservationSlot 拆分为 ReservationSlotGroup(按时段聚合)和 ReservationSpaceSlot(具体场地),提交/预览/记录接口新增 time_id、 token、week_start_date 字段,移除冗余的 venue_id;test.go 加入 .gitignore 作为本地调试脚本不纳入版本管理。 Co-Authored-By: Claude Sonnet 4.6 --- api/openapi/openapi.yaml | 93 ++- internal/api/gen/api.gen.go | 173 ++++-- internal/api/handler.go | 31 +- internal/service/reservation_service.go | 571 +++++++++++++++--- .../zjulogin/tyys_reservation_deprecated.go | 6 +- models/room_reservation.go | 4 +- 6 files changed, 692 insertions(+), 186 deletions(-) diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 5521b63..335e263 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -1102,29 +1102,56 @@ components: type: array items: $ref: '#/components/schemas/ReservationVenue' - ReservationSlot: + ReservationSpaceSlot: type: object - required: [slot_key, start_time, end_time, available] - description: 预约时间段 + required: [slot_key, venue_site_id, space_id, available, token] + description: 某时段内某个具体场地的预约信息 properties: slot_key: type: string - description: 时间段唯一标识,用于提交预约 - example: "123456" + description: 场地+时段唯一标识,格式 spaceId|timeId + example: "320|21996" + venue_site_id: + type: integer + format: int64 + space_id: + type: integer + format: int64 + space_name: + type: string + available: + type: boolean + token: + type: string + description: TYYS 预约令牌,提交时直接传入 + week_start_date: + type: string + format: date + description: TYYS 周开始日期,缺省则使用 reservation_date + ReservationSlotGroup: + type: object + required: [reservation_date, time_id, start_time, end_time, display_label, spaces] + description: 按时间段聚合的预约时段,包含该时段内所有可选场地 + properties: + reservation_date: + type: string + format: date + time_id: + type: integer + format: int64 start_time: type: string - description: 开始时间,格式 HH:mm 或 YYYY-MM-DD HH:mm - example: "2026-04-21 08:30" + example: "17: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: + example: "18:30" + display_label: type: string - description: 场地名称(如羽毛球场1号场) + example: "17:30-18:30" + spaces: + type: array + items: + $ref: '#/components/schemas/ReservationSpaceSlot' ReservationSlotListResponse: type: object required: [items] @@ -1132,7 +1159,7 @@ components: items: type: array items: - $ref: '#/components/schemas/ReservationSlot' + $ref: '#/components/schemas/ReservationSlotGroup' ReservationSubmitRequest: type: object required: @@ -1158,9 +1185,6 @@ components: type: string buddy_code: type: string - venue_id: - type: integer - format: int64 venue_site_id: type: integer format: int64 @@ -1169,6 +1193,17 @@ components: format: int64 space_name: type: string + time_id: + type: integer + format: int64 + description: TYYS 时段 ID,来自 slots 接口 + token: + type: string + description: TYYS 预约令牌,来自 slots 接口 + week_start_date: + type: string + format: date + description: TYYS 周开始日期,来自 slots 接口 ReservationPreviewResponse: type: object required: @@ -1204,9 +1239,6 @@ components: type: string buddy_code: type: string - venue_id: - type: integer - format: int64 venue_site_id: type: integer format: int64 @@ -1215,6 +1247,14 @@ components: format: int64 space_name: type: string + time_id: + type: integer + format: int64 + token: + type: string + week_start_date: + type: string + format: date ReservationRecordResponse: type: object required: @@ -1256,9 +1296,6 @@ components: type: string buddy_code: type: string - venue_id: - type: integer - format: int64 venue_site_id: type: integer format: int64 @@ -1267,6 +1304,14 @@ components: format: int64 space_name: type: string + time_id: + type: integer + format: int64 + token: + type: string + week_start_date: + type: string + format: date external_order_id: type: string external_trade_no: diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index db1bd69..9b41b62 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -91,57 +91,79 @@ type ReadyResponse struct { // ReservationPreviewResponse defines model for ReservationPreviewResponse. type ReservationPreviewResponse struct { - BuddyCode *string `json:"buddy_code,omitempty"` - CampusName string `json:"campus_name"` - EndTime string `json:"end_time"` - Provider string `json:"provider"` - ReservationDate openapi_types.Date `json:"reservation_date"` - ReservationStatus string `json:"reservation_status"` - RoomId int64 `json:"room_id"` - SpaceId *int64 `json:"space_id,omitempty"` - SpaceName *string `json:"space_name,omitempty"` - SportType string `json:"sport_type"` - StartTime string `json:"start_time"` - VenueId *int64 `json:"venue_id,omitempty"` - VenueName string `json:"venue_name"` - VenueSiteId *int64 `json:"venue_site_id,omitempty"` + BuddyCode *string `json:"buddy_code,omitempty"` + CampusName string `json:"campus_name"` + EndTime string `json:"end_time"` + Provider string `json:"provider"` + ReservationDate openapi_types.Date `json:"reservation_date"` + ReservationStatus string `json:"reservation_status"` + RoomId int64 `json:"room_id"` + SpaceId *int64 `json:"space_id,omitempty"` + SpaceName *string `json:"space_name,omitempty"` + SportType string `json:"sport_type"` + StartTime string `json:"start_time"` + TimeId *int64 `json:"time_id,omitempty"` + Token *string `json:"token,omitempty"` + VenueName string `json:"venue_name"` + VenueSiteId *int64 `json:"venue_site_id,omitempty"` + WeekStartDate *openapi_types.Date `json:"week_start_date,omitempty"` } // ReservationRecordResponse defines model for ReservationRecordResponse. type ReservationRecordResponse struct { - BuddyCode *string `json:"buddy_code,omitempty"` - CampusName string `json:"campus_name"` - CreatedAt time.Time `json:"created_at"` - EndTime string `json:"end_time"` - ExternalOrderId *string `json:"external_order_id,omitempty"` - ExternalTradeNo *string `json:"external_trade_no,omitempty"` - Id int64 `json:"id"` - Provider string `json:"provider"` - ReservationDate openapi_types.Date `json:"reservation_date"` - ReservationStatus string `json:"reservation_status"` - RoomId int64 `json:"room_id"` - SpaceId *int64 `json:"space_id,omitempty"` - SpaceName *string `json:"space_name,omitempty"` - SportType string `json:"sport_type"` - StartTime string `json:"start_time"` - UpdatedAt time.Time `json:"updated_at"` - VenueId *int64 `json:"venue_id,omitempty"` - VenueName string `json:"venue_name"` - VenueSiteId *int64 `json:"venue_site_id,omitempty"` -} - -// ReservationSlot defines model for ReservationSlot. -type ReservationSlot struct { - Available bool `json:"available"` - EndTime string `json:"end_time"` - SlotKey string `json:"slot_key"` - SpaceName *string `json:"space_name,omitempty"` - StartTime string `json:"start_time"` + BuddyCode *string `json:"buddy_code,omitempty"` + CampusName string `json:"campus_name"` + CreatedAt time.Time `json:"created_at"` + EndTime string `json:"end_time"` + ExternalOrderId *string `json:"external_order_id,omitempty"` + ExternalTradeNo *string `json:"external_trade_no,omitempty"` + Id int64 `json:"id"` + Provider string `json:"provider"` + ReservationDate openapi_types.Date `json:"reservation_date"` + ReservationStatus string `json:"reservation_status"` + RoomId int64 `json:"room_id"` + SpaceId *int64 `json:"space_id,omitempty"` + SpaceName *string `json:"space_name,omitempty"` + SportType string `json:"sport_type"` + StartTime string `json:"start_time"` + TimeId *int64 `json:"time_id,omitempty"` + Token *string `json:"token,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + VenueName string `json:"venue_name"` + VenueSiteId *int64 `json:"venue_site_id,omitempty"` + WeekStartDate *openapi_types.Date `json:"week_start_date,omitempty"` +} + +// ReservationSlotGroup 按时间段聚合的预约时段,包含该时段内所有可选场地 +type ReservationSlotGroup struct { + DisplayLabel string `json:"display_label"` + EndTime string `json:"end_time"` + ReservationDate openapi_types.Date `json:"reservation_date"` + Spaces []ReservationSpaceSlot `json:"spaces"` + StartTime string `json:"start_time"` + TimeId int64 `json:"time_id"` } // ReservationSlotListResponse defines model for ReservationSlotListResponse. type ReservationSlotListResponse struct { - Items []ReservationSlot `json:"items"` + Items []ReservationSlotGroup `json:"items"` +} + +// ReservationSpaceSlot 某时段内某个具体场地的预约信息 +type ReservationSpaceSlot struct { + Available bool `json:"available"` + + // SlotKey 场地+时段唯一标识,格式 spaceId|timeId + SlotKey string `json:"slot_key"` + SpaceId int64 `json:"space_id"` + SpaceName *string `json:"space_name,omitempty"` + + // Token TYYS 预约令牌,提交时直接传入 + Token string `json:"token"` + VenueSiteId int64 `json:"venue_site_id"` + + // WeekStartDate TYYS 周开始日期,缺省则使用 reservation_date + WeekStartDate *openapi_types.Date `json:"week_start_date,omitempty"` } // ReservationSubmitRequest defines model for ReservationSubmitRequest. @@ -154,16 +176,29 @@ type ReservationSubmitRequest struct { SpaceName *string `json:"space_name,omitempty"` SportType string `json:"sport_type"` StartTime string `json:"start_time"` - VenueId *int64 `json:"venue_id,omitempty"` - VenueName string `json:"venue_name"` - VenueSiteId *int64 `json:"venue_site_id,omitempty"` + + // TimeId TYYS 时段 ID,来自 slots 接口 + TimeId *int64 `json:"time_id,omitempty"` + + // Token TYYS 预约令牌,来自 slots 接口 + Token *string `json:"token,omitempty"` + VenueName string `json:"venue_name"` + VenueSiteId *int64 `json:"venue_site_id,omitempty"` + + // WeekStartDate TYYS 周开始日期,来自 slots 接口 + WeekStartDate *openapi_types.Date `json:"week_start_date,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 +282,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 +307,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 +365,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 +450,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 +465,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/api/handler.go b/internal/api/handler.go index 3132b66..907cee2 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -367,14 +367,33 @@ func (h *Handler) ListReservationVenues(c *gin.Context, params gen.ListReservati } func (h *Handler) ListReservationSlots(c *gin.Context, params gen.ListReservationSlotsParams) { - items, err := h.reservationService.ListSlots(c.Request.Context(), params.SportType, params.CampusName, params.VenueName, params.ReservationDate.String()) + groups, err := h.reservationService.ListSlots(c.Request.Context(), params.SportType, params.CampusName, params.VenueName, params.ReservationDate.String()) if err != nil { response.Error(c, http.StatusBadRequest, err.Error()) return } - resp := gen.ReservationSlotListResponse{Items: make([]gen.ReservationSlot, 0, len(items))} - for _, item := range items { - resp.Items = append(resp.Items, gen.ReservationSlot{SlotKey: item.SlotKey, StartTime: item.StartTime, EndTime: item.EndTime, Available: item.Available, SpaceName: item.SpaceName}) + resp := gen.ReservationSlotListResponse{Items: make([]gen.ReservationSlotGroup, 0, len(groups))} + for _, g := range groups { + spaces := make([]gen.ReservationSpaceSlot, 0, len(g.Spaces)) + for _, sp := range g.Spaces { + spaces = append(spaces, gen.ReservationSpaceSlot{ + SlotKey: sp.SlotKey, + VenueSiteId: sp.VenueSiteID, + SpaceId: sp.SpaceID, + SpaceName: stringPtrOrNil(sp.SpaceName), + Available: sp.Available, + Token: sp.Token, + WeekStartDate: parseDatePtr(sp.WeekStartDate), + }) + } + resp.Items = append(resp.Items, gen.ReservationSlotGroup{ + ReservationDate: openapi_types.Date{Time: mustParseDate(g.ReservationDate)}, + TimeId: g.TimeID, + StartTime: g.StartTime, + EndTime: g.EndTime, + DisplayLabel: g.DisplayLabel, + Spaces: spaces, + }) } response.JSON(c, http.StatusOK, resp) } @@ -489,10 +508,12 @@ func buildReservationPreviewInput(roomID uint, req gen.ReservationSubmitRequest) StartTime: req.StartTime, EndTime: req.EndTime, BuddyCode: req.BuddyCode, - VenueID: int64PtrToUintPtr(req.VenueId), VenueSiteID: int64PtrToUintPtr(req.VenueSiteId), SpaceID: int64PtrToUintPtr(req.SpaceId), SpaceName: req.SpaceName, + TimeID: int64PtrToStrPtr(req.TimeId), + Token: req.Token, + WeekStartDate: datePtrToStrPtr(req.WeekStartDate), } } diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 54b491c..c429071 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 { @@ -19,12 +22,27 @@ type ReservationVenueItem struct { VenueName string } -type ReservationSlotItem struct { - SlotKey string - StartTime string - EndTime string - Available bool - SpaceName *string +// ReservationSpaceSlotItem is a single bookable court within a time group. +// All fields needed to call ReserveV2 are embedded so the frontend only needs +// to pass them back verbatim — no re-query of dayInfo on submit. +type ReservationSpaceSlotItem struct { + SlotKey string // "{spaceId}|{timeId}" + VenueSiteID int64 + SpaceID int64 + SpaceName string + Available bool + Token string // top-level token from dayInfo response + WeekStartDate string // top-level weekStartDate from dayInfo response +} + +// ReservationSlotGroupItem groups all courts that share the same time window. +type ReservationSlotGroupItem struct { + ReservationDate string + TimeID int64 + StartTime string // HH:mm + EndTime string // HH:mm + DisplayLabel string // "HH:mm-HH:mm" + Spaces []ReservationSpaceSlotItem } type ReservationPreviewInput struct { @@ -36,10 +54,13 @@ type ReservationPreviewInput struct { StartTime string EndTime string BuddyCode *string - VenueID *uint VenueSiteID *uint SpaceID *uint SpaceName *string + // Slot context from ListSlots — when all four are set, TYYS re-query is skipped. + TimeID *string + Token *string + WeekStartDate *string } type ReservationPreviewOutput struct { @@ -53,10 +74,12 @@ type ReservationPreviewOutput struct { StartTime string EndTime string BuddyCode *string - VenueID *uint VenueSiteID *uint SpaceID *uint SpaceName *string + TimeID *string + Token *string + WeekStartDate *string } type ReservationService struct { @@ -99,129 +122,458 @@ func (s *ReservationService) ListVenues(ctx context.Context, sportType, campus * 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) +func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusName, venueName, reservationDate string) ([]ReservationSlotGroupItem, error) { + // Step 1: resolve venueId and venueSiteId from TYYS venue catalogue. + venueResp, err := s.tyys.VenueInfo(ctx, 0) + if err != nil { + return nil, fmt.Errorf("get venue info: %w", err) + } + + var venueID, venueSiteID string + walkVenues(venueResp.Data, func(obj map[string]any) { + if venueID != "" { + return } - return strconv.FormatFloat(val, 'f', -1, 64) - case int: - return strconv.Itoa(val) + if !textMatches(trimString(obj["sportName"]), sportType) { + return + } + if !textMatches(trimString(obj["campusName"]), campusName) { + return + } + if !textMatches(trimString(obj["venueName"]), 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) } - 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 + venueSiteIDInt, _ := strconv.ParseInt(venueSiteID, 10, 64) + + // Step 2: fetch day info for the requested date. + 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) } - walkJSONObjects(payload, func(obj map[string]any) { - if _, ok := obj["sportName"]; ok { - visit(obj) + + // token and weekStartDate are top-level fields in the dayInfo response, + // shared by all slots. Extract once and embed into every space entry. + token, weekStartDate := extractDayInfoMeta(dayResp.Data) + if weekStartDate == "" { + weekStartDate = reservationDate + } + + // Step 3: walk slots and aggregate by timeId. + // TYYS stores slots as: space{ id, spaceName, "": { startDate, endDate, … } } + // walkSlotsWithContext passes both the slot child and its parent space object. + var groups []ReservationSlotGroupItem + groupIdx := map[string]int{} // timeID string → index in groups + + walkSlotsWithContext(dayResp.Data, func(timeID string, slot, space map[string]any) { + startFull := trimString(slot["startDate"]) // "YYYY-MM-DD HH:mm" + endFull := trimString(slot["endDate"]) + startHHmm := extractHHmm(startFull) + endHHmm := extractHHmm(endFull) + + timeIDInt, _ := strconv.ParseInt(timeID, 10, 64) + + spaceIDStr := trimString(space["id"]) + spaceIDInt, _ := strconv.ParseInt(spaceIDStr, 10, 64) + + sp := ReservationSpaceSlotItem{ + SlotKey: spaceIDStr + "|" + timeID, + VenueSiteID: venueSiteIDInt, + SpaceID: spaceIDInt, + SpaceName: trimString(space["spaceName"]), + Available: isSlotAvailable(slot), + Token: token, + WeekStartDate: weekStartDate, + } + + if idx, exists := groupIdx[timeID]; exists { + groups[idx].Spaces = append(groups[idx].Spaces, sp) + } else { + groupIdx[timeID] = len(groups) + groups = append(groups, ReservationSlotGroupItem{ + ReservationDate: reservationDate, + TimeID: timeIDInt, + StartTime: startHHmm, + EndTime: endHHmm, + DisplayLabel: startHHmm + "-" + endHHmm, + Spaces: []ReservationSpaceSlotItem{sp}, + }) } }) + + return groups, nil } -// 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) +// Preview validates a proposed reservation against room state and TYYS availability +// without writing anything to the database. +func (s *ReservationService) Preview(ctx context.Context, input ReservationPreviewInput) (*ReservationPreviewOutput, error) { + 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) + } + + 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) + } + + venueSiteID, err := s.resolveAndVerifySlot(ctx, input) + if err != nil { + return nil, err + } + + venueSiteIDUint := uint(venueSiteID) + return &ReservationPreviewOutput{ + RoomID: input.RoomID, + Provider: "tyys", + ReservationStatus: "pending", + SportType: input.SportType, + CampusName: input.CampusName, + VenueName: input.VenueName, + ReservationDate: input.ReservationDate, + StartTime: input.StartTime, + EndTime: input.EndTime, + BuddyCode: input.BuddyCode, + VenueSiteID: &venueSiteIDUint, + SpaceID: input.SpaceID, + SpaceName: input.SpaceName, + TimeID: input.TimeID, + Token: input.Token, + WeekStartDate: input.WeekStartDate, + }, nil +} + +// Submit validates the reservation, writes a RoomReservation record, and determines +// its initial status based on the TYYS opening-time rule. +func (s *ReservationService) Submit(ctx context.Context, input ReservationPreviewInput) (*models.RoomReservation, error) { + // --- 1. Validate room state --- + 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) + } + + 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 check --- + 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 / slot availability --- + venueSiteID, err := s.resolveAndVerifySlot(ctx, input) + if err != nil { + return nil, err + } + + // --- 4. TYYS opening-time rule --- + 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) { + status = "submitting" + } + + // --- 5. Persist --- + venueSiteIDUint := uint(venueSiteID) + buddyCode := "" + if input.BuddyCode != nil { + buddyCode = *input.BuddyCode + } + spaceName := "" + if input.SpaceName != nil { + spaceName = *input.SpaceName + } + timeID := "" + if input.TimeID != nil { + timeID = *input.TimeID + } + token := "" + if input.Token != nil { + token = *input.Token + } + weekStartDate := "" + if input.WeekStartDate != nil { + weekStartDate = *input.WeekStartDate + } + + 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, + VenueSiteID: &venueSiteIDUint, + SpaceID: input.SpaceID, + SpaceName: spaceName, + TimeID: timeID, + Token: token, + WeekStartDate: weekStartDate, + 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 room --- + room.ReservationStatus = status + if updateErr := s.roomRepo.Update(ctx, room); updateErr != nil { + _ = s.logAttempt(ctx, input.RoomID, reservation.ID, "update_room_status", false, updateErr.Error()) + } + + _ = 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 } -// 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 +// resolveAndVerifySlot returns the resolved venueSiteID (as int64) and verifies +// slot availability. When the full slot context (VenueSiteID, SpaceID, TimeID, Token) +// is already provided from ListSlots, TYYS re-query is skipped entirely. +func (s *ReservationService) resolveAndVerifySlot(ctx context.Context, input ReservationPreviewInput) (int64, error) { + if slotContextComplete(input) { + return int64(*input.VenueSiteID), nil } - got = strings.TrimSpace(got) - return got == want || strings.Contains(got, want) || strings.Contains(want, got) + + // Fallback: look up venue and verify slot by start/end time. + venueID, venueSiteID, err := s.lookupVenueIDs(ctx, input) + if err != nil { + return 0, err + } + if err := s.checkSlotAvailable(ctx, venueID, venueSiteID, input); err != nil { + return 0, err + } + id, _ := strconv.ParseInt(venueSiteID, 10, 64) + return id, nil } -func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusName, venueName, reservationDate string) ([]ReservationSlotItem, error) { - // Step 1: Get venue info to find venueId and venueSiteId +// slotContextComplete returns true when the caller already has all fields +// needed to call ReserveV2 without querying TYYS again. +func slotContextComplete(input ReservationPreviewInput) bool { + return input.VenueSiteID != nil && input.SpaceID != nil && + input.TimeID != nil && input.Token != nil +} + +func (s *ReservationService) lookupVenueIDs(ctx context.Context, input ReservationPreviewInput) (venueID, venueSiteID string, err error) { venueResp, err := s.tyys.VenueInfo(ctx, 0) if err != nil { - return nil, fmt.Errorf("get venue info: %w", err) + return "", "", 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 + return } - sportGot := trimString(obj["sportName"]) - campusGot := trimString(obj["campusName"]) - venueGot := trimString(obj["venueName"]) - if !textMatches(sportGot, sportType) { + if !textMatches(trimString(obj["sportName"]), input.SportType) { return } - if !textMatches(campusGot, campusName) { + if !textMatches(trimString(obj["campusName"]), input.CampusName) { return } - if !textMatches(venueGot, venueName) { + if !textMatches(trimString(obj["venueName"]), input.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) + return "", "", fmt.Errorf("venue not found for sport=%s campus=%s venue=%s", + input.SportType, input.CampusName, input.VenueName) } + return venueID, venueSiteID, nil +} - // Step 2: Get day info (available slots) +// checkSlotAvailable queries TYYS day info and confirms the start/end time slot +// exists and has at least one free court. +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) - params.Set("date", reservationDate) - params.Set("reservationDate", reservationDate) - params.Set("searchDate", reservationDate) + 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 nil, fmt.Errorf("get day info: %w", err) + return 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), + wantStart := input.ReservationDate + " " + input.StartTime + wantEnd := input.ReservationDate + " " + input.EndTime + found, available := false, false + + walkSlotsWithContext(dayResp.Data, func(_ string, slot, _ map[string]any) { + if available { + return } - if name := trimString(slot["spaceName"]); name != "" { - item.SpaceName = &name + if trimString(slot["startDate"]) == wantStart && trimString(slot["endDate"]) == wantEnd { + found = true + if isSlotAvailable(slot) { + available = true + } } - slots = append(slots, item) }) - return slots, nil + 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 +} + +// tyysOpenTime returns 09:00 CST exactly 2 calendar days before the reservation 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 +} + +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) +} + +// ── sport config ───────────────────────────────────────────────────────────── + +type sportConfig struct { + RequiresBuddyCode bool + MinMemberCount int +} + +// getSportConfig returns booking rules for a sport type. +// Badminton and tennis require a buddy code and at least 2 members. +func getSportConfig(sportType string) sportConfig { + switch sportType { + case "羽毛球", "网球": + return sportConfig{RequiresBuddyCode: true, MinMemberCount: 2} + default: + return sportConfig{RequiresBuddyCode: false, MinMemberCount: 1} + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +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 "" +} + +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) +} + +// extractHHmm extracts "HH:mm" from a "YYYY-MM-DD HH:mm" string. +func extractHHmm(datetime string) string { + if i := strings.LastIndex(datetime, " "); i >= 0 { + return datetime[i+1:] + } + return datetime +} + +// extractDayInfoMeta reads token and weekStartDate from the top level of a +// TYYS dayInfo response. Both fields are shared across all slots in the response. +func extractDayInfoMeta(data []byte) (token, weekStartDate string) { + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + return + } + token = trimString(obj["token"]) + weekStartDate = trimString(obj["weekStartDate"]) + return } -// 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 @@ -235,24 +587,61 @@ func isSlotAvailable(slot map[string]any) bool { return true } -// walkSlots walks through parsed JSON and visits each slot object. -func walkSlots(data []byte, visit func(map[string]any)) { +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) { - // A slot object has startDate and endDate fields - if _, hasStart := obj["startDate"]; hasStart { + if _, ok := obj["sportName"]; ok { visit(obj) } }) } -func (s *ReservationService) Preview(ctx context.Context, input ReservationPreviewInput) (*ReservationPreviewOutput, error) { - return nil, fmt.Errorf("reservation service Preview not implemented") +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) + } + } } -func (s *ReservationService) Submit(ctx context.Context, input ReservationPreviewInput) (*models.RoomReservation, error) { - return nil, fmt.Errorf("reservation service Submit not implemented") +// walkSlotsWithContext walks the TYYS dayInfo response and calls visit for each +// slot object (identified by having a "startDate" field), passing the timeID +// (the map key under which the slot is stored) and the parent space object. +// TYYS structure: space{ id, spaceName, "": { startDate, endDate, … } } +func walkSlotsWithContext(data []byte, visit func(timeID string, slot, space map[string]any)) { + var payload any + if err := json.Unmarshal(data, &payload); err != nil { + return + } + var walk func(any) + walk = func(value any) { + switch typed := value.(type) { + case map[string]any: + for k, child := range typed { + if obj, ok := child.(map[string]any); ok { + if _, hasStart := obj["startDate"]; hasStart { + visit(k, obj, typed) // typed is the parent space object + } else { + walk(obj) + } + } else { + walk(child) + } + } + case []any: + for _, child := range typed { + walk(child) + } + } + } + walk(payload) } diff --git a/internal/zjulogin/tyys_reservation_deprecated.go b/internal/zjulogin/tyys_reservation_deprecated.go index 7eff4ef..bb64779 100644 --- a/internal/zjulogin/tyys_reservation_deprecated.go +++ b/internal/zjulogin/tyys_reservation_deprecated.go @@ -1,5 +1,7 @@ -// Deprecated: This function is a redundant implementation. -// Use tyys_reservation_v2 instead for better maintenance and performance. +//go:build ignore + +// Deprecated: This file documents functions in tyys_reservation.go that are +// superseded by tyys_reservation_v2.go. Do not use these directly; call ReserveV2 instead. package zjulogin import ( diff --git a/models/room_reservation.go b/models/room_reservation.go index 91bbe52..9837be5 100644 --- a/models/room_reservation.go +++ b/models/room_reservation.go @@ -12,10 +12,12 @@ type RoomReservation struct { ReservationDate string `gorm:"size:10;not null"` StartTime string `gorm:"size:16;not null"` EndTime string `gorm:"size:16;not null"` - VenueID *uint VenueSiteID *uint SpaceID *uint SpaceName string `gorm:"size:64"` + TimeID string `gorm:"size:32"` + Token string `gorm:"size:256"` + WeekStartDate string `gorm:"size:10"` BuddyCode string `gorm:"size:32"` BuddyUserIDs string `gorm:"type:text"` ReservationStatus string `gorm:"size:32;not null;default:'pending';index"` From db0295c34b6b3928d6ed7d7b5af8e9c1980a9a55 Mon Sep 17 00:00:00 2001 From: GP Date: Sun, 26 Apr 2026 21:41:08 +0800 Subject: [PATCH 3/3] feat(reservation): implement async TYYS submit and TriggerReservation endpoint - Add TriggerReservation service method: reads stored slot context and calls ReserveV2 without re-querying TYYS dayInfo; updates status to success/failed - Add /internal/tasks/reservation-trigger endpoint for scheduler use - Fire background goroutine in Submit when TYYS window is already open; returns submitting immediately, goroutine updates final status - Fix ListVenues to propagate TYYS errors instead of silently returning nil - Add GetByID and Update to ReservationRepository - Wire TYYSPythonCaptchaSolver into ReservationService constructor Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +- cmd/server/main.go | 3 +- internal/api/handler.go | 80 +++++++++++- internal/api/router.go | 3 + internal/repository/reservation_repository.go | 12 ++ internal/service/reservation_service.go | 115 +++++++++++++++++- 6 files changed, 205 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index b495e5f..23093bc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ __pycache__/ .claude/ docs/ prd.md -claude.md \ No newline at end of file +claude.md +test.go \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 260fb5e..1c141fe 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -81,7 +81,8 @@ func main() { if err != nil { log.Fatal("initialize TYYS client", zap.Error(err)) } - reservationService := service.NewReservationService(roomRepository, reservationRepository, tyys) + captchaSolver := zjulogin.TYYSPythonCaptchaSolver{} + reservationService := service.NewReservationService(roomRepository, reservationRepository, tyys, captchaSolver) engine := api.NewRouter(log, sqlDB, userService, roomService, reservationService) server := &http.Server{ diff --git a/internal/api/handler.go b/internal/api/handler.go index 907cee2..b08b71c 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "net/http" + "strconv" "time" "github.com/QSCTech/SRTP-Backend/internal/api/gen" @@ -358,7 +359,11 @@ func (h *Handler) RemoveRoomMember(c *gin.Context, roomId int64, userId int64) { } func (h *Handler) ListReservationVenues(c *gin.Context, params gen.ListReservationVenuesParams) { - items := h.reservationService.ListVenues(c.Request.Context(), params.SportType, params.Campus) + items, err := h.reservationService.ListVenues(c.Request.Context(), params.SportType, params.Campus) + if err != nil { + response.Error(c, http.StatusBadGateway, "failed to fetch venues") + return + } resp := gen.ReservationVenueListResponse{Items: make([]gen.ReservationVenue, 0, len(items))} for _, item := range items { resp.Items = append(resp.Items, gen.ReservationVenue{SportType: item.SportType, CampusName: item.CampusName, VenueName: item.VenueName}) @@ -426,6 +431,22 @@ func (h *Handler) SubmitRoomReservation(c *gin.Context, roomId int64) { response.JSON(c, http.StatusOK, buildReservationRecordResponse(reservation)) } +func (h *Handler) TriggerReservationTask(c *gin.Context) { + var req struct { + ReservationID uint `json:"reservation_id"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.ReservationID == 0 { + response.Error(c, http.StatusBadRequest, "reservation_id is required") + return + } + reservation, err := h.reservationService.TriggerReservation(c.Request.Context(), req.ReservationID) + if err != nil { + response.Error(c, http.StatusBadRequest, err.Error()) + return + } + response.JSON(c, http.StatusOK, buildReservationRecordResponse(reservation)) +} + func buildUserResponse(user *models.User) gen.UserResponse { return gen.UserResponse{Id: int64(user.ID), AuthUid: user.AuthUID, Nickname: user.Nickname, AvatarUrl: user.AvatarURL, Gender: user.Gender, Bio: user.Bio, ProfileStatus: user.ProfileStatus, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt} } @@ -529,10 +550,12 @@ func buildReservationPreviewResponse(preview *service.ReservationPreviewOutput) StartTime: preview.StartTime, EndTime: preview.EndTime, BuddyCode: preview.BuddyCode, - VenueId: uintPtrToInt64Ptr(preview.VenueID), VenueSiteId: uintPtrToInt64Ptr(preview.VenueSiteID), SpaceId: uintPtrToInt64Ptr(preview.SpaceID), SpaceName: preview.SpaceName, + TimeId: strPtrToInt64Ptr(preview.TimeID), + Token: preview.Token, + WeekStartDate: strPtrToDatePtr(preview.WeekStartDate), } } @@ -549,10 +572,12 @@ func buildReservationRecordResponse(reservation *models.RoomReservation) gen.Res StartTime: reservation.StartTime, EndTime: reservation.EndTime, BuddyCode: stringPtrOrNil(reservation.BuddyCode), - VenueId: uintPtrToInt64Ptr(reservation.VenueID), VenueSiteId: uintPtrToInt64Ptr(reservation.VenueSiteID), SpaceId: uintPtrToInt64Ptr(reservation.SpaceID), SpaceName: stringPtrOrNil(reservation.SpaceName), + TimeId: stringToInt64Ptr(reservation.TimeID), + Token: stringPtrOrNil(reservation.Token), + WeekStartDate: parseDatePtr(reservation.WeekStartDate), ExternalOrderId: stringPtrOrNil(reservation.ExternalOrderID), ExternalTradeNo: stringPtrOrNil(reservation.ExternalTradeNo), CreatedAt: reservation.CreatedAt, @@ -627,3 +652,52 @@ func mustParseDate(value string) time.Time { } return t } + +func parseDatePtr(s string) *openapi_types.Date { + if s == "" { + return nil + } + d := openapi_types.Date{Time: mustParseDate(s)} + return &d +} + +func stringToInt64Ptr(s string) *int64 { + if s == "" { + return nil + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return nil + } + return &n +} + +func int64PtrToStrPtr(v *int64) *string { + if v == nil { + return nil + } + s := strconv.FormatInt(*v, 10) + return &s +} + +func strPtrToInt64Ptr(v *string) *int64 { + if v == nil { + return nil + } + return stringToInt64Ptr(*v) +} + +func datePtrToStrPtr(v *openapi_types.Date) *string { + if v == nil { + return nil + } + s := v.Format("2006-01-02") + return &s +} + +func strPtrToDatePtr(v *string) *openapi_types.Date { + if v == nil { + return nil + } + return parseDatePtr(*v) +} diff --git a/internal/api/router.go b/internal/api/router.go index f9c6b44..b44f19a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -17,5 +17,8 @@ func NewRouter(log *zap.Logger, db *sql.DB, userService *service.UserService, ro handler := NewHandler(db, userService, roomService, reservationService) gen.RegisterHandlers(engine, handler) + // Internal endpoint for the reservation scheduler to trigger a pending reservation. + engine.POST("/internal/tasks/reservation-trigger", handler.TriggerReservationTask) + return engine } diff --git a/internal/repository/reservation_repository.go b/internal/repository/reservation_repository.go index af5509e..5c217d4 100644 --- a/internal/repository/reservation_repository.go +++ b/internal/repository/reservation_repository.go @@ -30,3 +30,15 @@ func (r *ReservationRepository) GetLatestByRoomID(ctx context.Context, roomID ui } return &reservation, nil } + +func (r *ReservationRepository) GetByID(ctx context.Context, id uint) (*models.RoomReservation, error) { + var reservation models.RoomReservation + if err := r.db.WithContext(ctx).First(&reservation, id).Error; err != nil { + return nil, err + } + return &reservation, nil +} + +func (r *ReservationRepository) Update(ctx context.Context, reservation *models.RoomReservation) error { + return r.db.WithContext(ctx).Save(reservation).Error +} diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index c429071..677ce7d 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -86,16 +86,17 @@ type ReservationService struct { roomRepo *repository.RoomRepository reservationRepo *repository.ReservationRepository tyys *zjulogin.TYYS + captchaSolver zjulogin.TYYSCaptchaSolver } -func NewReservationService(roomRepo *repository.RoomRepository, reservationRepo *repository.ReservationRepository, tyys *zjulogin.TYYS) *ReservationService { - return &ReservationService{roomRepo: roomRepo, reservationRepo: reservationRepo, tyys: tyys} +func NewReservationService(roomRepo *repository.RoomRepository, reservationRepo *repository.ReservationRepository, tyys *zjulogin.TYYS, captchaSolver zjulogin.TYYSCaptchaSolver) *ReservationService { + return &ReservationService{roomRepo: roomRepo, reservationRepo: reservationRepo, tyys: tyys, captchaSolver: captchaSolver} } -func (s *ReservationService) ListVenues(ctx context.Context, sportType, campus *string) []ReservationVenueItem { +func (s *ReservationService) ListVenues(ctx context.Context, sportType, campus *string) ([]ReservationVenueItem, error) { resp, err := s.tyys.VenueInfo(ctx, 0) - if err != nil || resp == nil { - return nil + if err != nil { + return nil, fmt.Errorf("get venue info: %w", err) } var result []ReservationVenueItem @@ -119,7 +120,7 @@ func (s *ReservationService) ListVenues(ctx context.Context, sportType, campus * VenueName: venue, }) }) - return result + return result, nil } func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusName, venueName, reservationDate string) ([]ReservationSlotGroupItem, error) { @@ -377,6 +378,19 @@ func (s *ReservationService) Submit(ctx context.Context, input ReservationPrevie return nil, fmt.Errorf("create reservation: %w", err) } + // If the TYYS window is already open, trigger in the background. + // The reservation is returned with status "submitting"; the goroutine + // updates it to "success" or "failed" once TYYS responds. + if status == "submitting" { + reservationID := reservation.ID + go func() { + ctx := context.Background() + if _, err := s.TriggerReservation(ctx, reservationID); err != nil { + _ = s.logAttempt(ctx, reservation.RoomID, reservationID, "async_trigger", false, err.Error()) + } + }() + } + // --- 6. Mirror status onto room --- room.ReservationStatus = status if updateErr := s.roomRepo.Update(ctx, room); updateErr != nil { @@ -499,6 +513,95 @@ func tyysOpenTime(reservationDate string) (time.Time, error) { return time.Date(open.Year(), open.Month(), open.Day(), 9, 0, 0, 0, loc), nil } +// TriggerReservation executes the TYYS reservation for an existing record. +// It reads the stored slot context (venue_site_id, space_id, time_id, token, +// week_start_date) and calls ReserveV2 directly — no dayInfo re-query is made. +func (s *ReservationService) TriggerReservation(ctx context.Context, reservationID uint) (*models.RoomReservation, error) { + reservation, err := s.reservationRepo.GetByID(ctx, reservationID) + if err != nil { + return nil, fmt.Errorf("get reservation: %w", err) + } + if reservation.ReservationStatus != "submitting" { + return nil, fmt.Errorf("reservation %d has status %q, expected submitting", reservationID, reservation.ReservationStatus) + } + if reservation.VenueSiteID == nil || reservation.SpaceID == nil || reservation.TimeID == "" || reservation.Token == "" { + return nil, fmt.Errorf("reservation %d is missing required slot context", reservationID) + } + + // Mark as running before calling TYYS. + now := time.Now() + reservation.SubmitAttemptedAt = &now + reservation.ScheduleStatus = "running" + if err := s.reservationRepo.Update(ctx, reservation); err != nil { + return nil, fmt.Errorf("update reservation before trigger: %w", err) + } + + result, tyysErr := s.tyys.ReserveV2(ctx, zjulogin.TYYSReservationV2Request{ + ReservationDate: reservation.ReservationDate, + WeekStartDate: reservation.WeekStartDate, + Token: reservation.Token, + VenueSiteID: fmt.Sprintf("%d", *reservation.VenueSiteID), + SpaceID: fmt.Sprintf("%d", *reservation.SpaceID), + TimeID: reservation.TimeID, + BuddyCode: reservation.BuddyCode, + CaptchaSolver: s.captchaSolver, + }) + + success := tyysErr == nil && result != nil && result.Submit != nil + if success { + orderID, tradeNo := extractTYYSSubmitOrderInfo(result.Submit.Data) + reservation.ExternalOrderID = orderID + reservation.ExternalTradeNo = tradeNo + reservation.RawResponse = string(result.Submit.Data) + reservation.ReservationStatus = "success" + reservation.ScheduleStatus = "done" + } else { + reservation.ReservationStatus = "failed" + reservation.ScheduleStatus = "error" + if result != nil && result.Submit != nil { + reservation.RawResponse = string(result.Submit.Data) + } + } + + if err := s.reservationRepo.Update(ctx, reservation); err != nil { + _ = s.logAttempt(ctx, reservation.RoomID, reservationID, "trigger_update", false, err.Error()) + return nil, fmt.Errorf("update reservation after trigger: %w", err) + } + + // Mirror status onto room. + if room, err := s.roomRepo.GetByID(ctx, reservation.RoomID); err == nil { + room.ReservationStatus = reservation.ReservationStatus + _ = s.roomRepo.Update(ctx, room) + } + + msg := "" + if tyysErr != nil { + msg = tyysErr.Error() + } + _ = s.logAttempt(ctx, reservation.RoomID, reservationID, "trigger_reservation", success, msg) + + if tyysErr != nil { + return reservation, fmt.Errorf("tyys reserve: %w", tyysErr) + } + return reservation, nil +} + +// extractTYYSSubmitOrderInfo pulls order identifiers from a TYYS submit response. +func extractTYYSSubmitOrderInfo(data json.RawMessage) (orderID, tradeNo string) { + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + return + } + for _, key := range []string{"orderId", "orderSn", "id"} { + if v := trimString(obj[key]); v != "" { + orderID = v + break + } + } + tradeNo = trimString(obj["tradeNo"]) + return +} + func (s *ReservationService) logAttempt(ctx context.Context, roomID, reservationID uint, stage string, success bool, message string) error { entry := &models.ReservationAttemptLog{ RoomID: &roomID,