From 347555fab08a5af4ca6106711b1e5575250e4931 Mon Sep 17 00:00:00 2001 From: GP Date: Mon, 20 Apr 2026 12:56:34 +0800 Subject: [PATCH 1/8] =?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 a956dfc64df09d27a0764635b30526b5dec36d0a Mon Sep 17 00:00:00 2001 From: GP Date: Wed, 29 Apr 2026 21:25:04 +0800 Subject: [PATCH 2/8] open api settings for clean room_reservation --- api/openapi/openapi.yaml | 239 +++++++++++++++++++++++++++++- internal/api/gen/api.gen.go | 286 ++++++++++++++++++++++++++++++++---- models/room_reservation.go | 3 + test.go | 102 +++++++++++++ 4 files changed, 604 insertions(+), 26 deletions(-) create mode 100644 test.go diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 5521b63..5ea4516 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.0.3 info: title: SRTP Backend API version: 0.2.0 @@ -610,6 +610,122 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /reservations/templates: + get: + operationId: listReservationTemplates + summary: List venue template (static structure) + description: | + 查询场馆的固定结构信息(分场列表、固定时间段模板)。 + 不依赖 TYYS 实时查询窗口,适合在创建远期预约计划前让用户选择时间段。 + parameters: + - in: query + name: sport_type + required: true + schema: + type: string + - in: query + name: campus_name + required: true + schema: + type: string + - in: query + name: venue_name + required: true + schema: + type: string + responses: + '200': + description: Venue template + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationTemplateResponse' + '400': + description: Invalid query + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /rooms/{roomId}/reservation/plan: + post: + operationId: createRoomReservationPlan + summary: Create a reservation plan for a future date (>2 days) + description: | + 为 2 天后的日期创建预约计划,保存预约意图到数据库。 + 系统会在预约日期前 2 天的上午 09:00 自动补全 slot 上下文并提交 TYYS。 + parameters: + - $ref: '#/components/parameters/RoomIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationPlanRequest' + responses: + '200': + description: Plan created + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationRecordResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /internal/tasks/reservation-materialize: + post: + operationId: triggerReservationMaterialize + summary: Materialize scheduled reservation plans (internal scheduler use) + description: | + 供调度器调用。将指定日期的 scheduled 计划补全为可执行的 slot 上下文,然后触发提交。 + 每天 09:00 由调度器对"两天后"的计划执行一次。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationMaterializeRequest' + responses: + '200': + description: Materialize result + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationMaterializeResult' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /internal/tasks/reservation-trigger: + post: + operationId: triggerReservation + summary: Trigger a specific reservation submission (internal scheduler use) + description: | + 供调度器调用。原子地将预约状态从 pending 切换到 submitting 并提交 TYYS。 + 包含验证码失败重试(最多 5 次)。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationTriggerRequest' + responses: + '200': + description: Trigger result + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationRecordResponse' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' components: parameters: UserIdPath: @@ -1169,6 +1285,17 @@ components: format: int64 space_name: type: string + time_id: + type: integer + format: int64 + description: TYYS 时间段ID,实时预约必填 + token: + type: string + description: TYYS slot token,实时预约必填 + week_start_date: + type: string + format: date + description: TYYS 周起始日期,默认取 reservation_date ReservationPreviewResponse: type: object required: @@ -1277,3 +1404,113 @@ components: updated_at: type: string format: date-time + ReservationTemplateSpace: + type: object + required: [space_id, space_name] + properties: + space_id: + type: integer + format: int64 + space_name: + type: string + ReservationTemplateTimeSlot: + type: object + required: [start_time, end_time, display_label] + properties: + time_id: + type: integer + format: int64 + start_time: + type: string + example: "08:00" + end_time: + type: string + example: "09:00" + display_label: + type: string + example: "08:00-09:00" + ReservationTemplateResponse: + type: object + required: [sport_type, campus_name, venue_name, venue_site_id, spaces, time_slots] + description: 场馆固定结构信息,用于创建预约计划时选择时间段 + properties: + sport_type: + type: string + campus_name: + type: string + venue_name: + type: string + venue_id: + type: integer + format: int64 + venue_site_id: + type: integer + format: int64 + spaces: + type: array + items: + $ref: '#/components/schemas/ReservationTemplateSpace' + time_slots: + type: array + items: + $ref: '#/components/schemas/ReservationTemplateTimeSlot' + ReservationPlanRequest: + type: object + required: + - sport_type + - campus_name + - venue_name + - reservation_date + - start_time + - end_time + description: 创建预约计划请求(仅保存预约意图,不立即调 TYYS) + properties: + sport_type: + type: string + campus_name: + type: string + venue_name: + type: string + reservation_date: + type: string + format: date + start_time: + type: string + example: "08:00" + end_time: + type: string + example: "09:00" + buddy_code: + type: string + ReservationTriggerRequest: + type: object + required: [reservation_id] + description: 触发单条预约提交(供调度器调用) + properties: + reservation_id: + type: integer + format: int64 + ReservationMaterializeRequest: + type: object + description: | + 触发预约计划补全(供调度器调用)。 + 后端自动查找 reserve_open_at <= now 且 status = scheduled 的记录,无需传入日期。 + properties: + dry_run: + type: boolean + description: 若为 true,只返回待处理计划数量,不实际执行 + ReservationMaterializeResult: + type: object + required: [total, succeeded, failed] + description: 预约计划批量补全结果,返回本次调度处理的计划数量及失败详情 + properties: + total: + type: integer + succeeded: + type: integer + failed: + type: integer + errors: + type: array + items: + type: string diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index db1bd69..27bb9ec 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -89,6 +89,32 @@ type ReadyResponse struct { Status string `json:"status"` } +// ReservationMaterializeRequest 触发预约计划补全(供调度器调用)。 +// 后端自动查找 reserve_open_at <= now 且 status = scheduled 的记录,无需传入日期。 +type ReservationMaterializeRequest struct { + // DryRun 若为 true,只返回待处理计划数量,不实际执行 + DryRun *bool `json:"dry_run,omitempty"` +} + +// ReservationMaterializeResult 预约计划批量补全结果,返回本次调度处理的计划数量及失败详情 +type ReservationMaterializeResult struct { + Errors *[]string `json:"errors,omitempty"` + Failed int `json:"failed"` + Succeeded int `json:"succeeded"` + Total int `json:"total"` +} + +// ReservationPlanRequest 创建预约计划请求(仅保存预约意图,不立即调 TYYS) +type ReservationPlanRequest struct { + BuddyCode *string `json:"buddy_code,omitempty"` + CampusName string `json:"campus_name"` + EndTime string `json:"end_time"` + ReservationDate openapi_types.Date `json:"reservation_date"` + SportType string `json:"sport_type"` + StartTime string `json:"start_time"` + VenueName string `json:"venue_name"` +} + // ReservationPreviewResponse defines model for ReservationPreviewResponse. type ReservationPreviewResponse struct { BuddyCode *string `json:"buddy_code,omitempty"` @@ -130,13 +156,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. @@ -154,16 +189,60 @@ 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,实时预约必填 + TimeId *int64 `json:"time_id,omitempty"` + + // Token TYYS slot token,实时预约必填 + Token *string `json:"token,omitempty"` + VenueId *int64 `json:"venue_id,omitempty"` + VenueName string `json:"venue_name"` + VenueSiteId *int64 `json:"venue_site_id,omitempty"` + + // WeekStartDate TYYS 周起始日期,默认取 reservation_date + WeekStartDate *openapi_types.Date `json:"week_start_date,omitempty"` +} + +// ReservationTemplateResponse 场馆固定结构信息,用于创建预约计划时选择时间段 +type ReservationTemplateResponse struct { + CampusName string `json:"campus_name"` + Spaces []ReservationTemplateSpace `json:"spaces"` + SportType string `json:"sport_type"` + TimeSlots []ReservationTemplateTimeSlot `json:"time_slots"` + VenueId *int64 `json:"venue_id,omitempty"` + VenueName string `json:"venue_name"` + VenueSiteId int64 `json:"venue_site_id"` +} + +// ReservationTemplateSpace defines model for ReservationTemplateSpace. +type ReservationTemplateSpace struct { + SpaceId int64 `json:"space_id"` + SpaceName string `json:"space_name"` +} + +// ReservationTemplateTimeSlot defines model for ReservationTemplateTimeSlot. +type ReservationTemplateTimeSlot struct { + DisplayLabel string `json:"display_label"` + EndTime string `json:"end_time"` + StartTime string `json:"start_time"` + TimeId *int64 `json:"time_id,omitempty"` } -// ReservationVenue defines model for ReservationVenue. +// ReservationTriggerRequest 触发单条预约提交(供调度器调用) +type ReservationTriggerRequest struct { + ReservationId int64 `json:"reservation_id"` +} + +// 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 +326,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 +351,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 +409,33 @@ 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"` } +// ListReservationTemplatesParams defines parameters for ListReservationTemplates. +type ListReservationTemplatesParams 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"` +} + // 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. @@ -356,6 +454,12 @@ type ListRoomsParams struct { // LoginWithWechatJSONRequestBody defines body for LoginWithWechat for application/json ContentType. type LoginWithWechatJSONRequestBody = WxLoginRequest +// TriggerReservationMaterializeJSONRequestBody defines body for TriggerReservationMaterialize for application/json ContentType. +type TriggerReservationMaterializeJSONRequestBody = ReservationMaterializeRequest + +// TriggerReservationJSONRequestBody defines body for TriggerReservation for application/json ContentType. +type TriggerReservationJSONRequestBody = ReservationTriggerRequest + // UpdateCurrentUserProfileJSONRequestBody defines body for UpdateCurrentUserProfile for application/json ContentType. type UpdateCurrentUserProfileJSONRequestBody = UpdateProfileRequest @@ -380,6 +484,9 @@ type InviteRoomMemberJSONRequestBody = InviteMemberRequest // RejectJoinRequestJSONRequestBody defines body for RejectJoinRequest for application/json ContentType. type RejectJoinRequestJSONRequestBody = ReviewJoinRequestRequest +// CreateRoomReservationPlanJSONRequestBody defines body for CreateRoomReservationPlan for application/json ContentType. +type CreateRoomReservationPlanJSONRequestBody = ReservationPlanRequest + // PreviewRoomReservationJSONRequestBody defines body for PreviewRoomReservation for application/json ContentType. type PreviewRoomReservationJSONRequestBody = ReservationSubmitRequest @@ -400,10 +507,16 @@ type ServerInterface interface { // Service health check // (GET /healthz) GetHealthz(c *gin.Context) + // Materialize scheduled reservation plans (internal scheduler use) + // (POST /internal/tasks/reservation-materialize) + TriggerReservationMaterialize(c *gin.Context) + // Trigger a specific reservation submission (internal scheduler use) + // (POST /internal/tasks/reservation-trigger) + TriggerReservation(c *gin.Context) // 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,9 +531,12 @@ 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 venue template (static structure) + // (GET /reservations/templates) + ListReservationTemplates(c *gin.Context, params ListReservationTemplatesParams) // List supported reservation venues // (GET /reservations/venues) ListReservationVenues(c *gin.Context, params ListReservationVenuesParams) @@ -460,6 +576,9 @@ type ServerInterface interface { // Reject a join request // (POST /rooms/{roomId}/reject) RejectJoinRequest(c *gin.Context, roomId RoomIdPath) + // Create a reservation plan for a future date (>2 days) + // (POST /rooms/{roomId}/reservation/plan) + CreateRoomReservationPlan(c *gin.Context, roomId RoomIdPath) // Preview reservation for a room // (POST /rooms/{roomId}/reservation/preview) PreviewRoomReservation(c *gin.Context, roomId RoomIdPath) @@ -522,6 +641,32 @@ func (siw *ServerInterfaceWrapper) GetHealthz(c *gin.Context) { siw.Handler.GetHealthz(c) } +// TriggerReservationMaterialize operation middleware +func (siw *ServerInterfaceWrapper) TriggerReservationMaterialize(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.TriggerReservationMaterialize(c) +} + +// TriggerReservation operation middleware +func (siw *ServerInterfaceWrapper) TriggerReservation(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.TriggerReservation(c) +} + // GetCurrentUser operation middleware func (siw *ServerInterfaceWrapper) GetCurrentUser(c *gin.Context) { @@ -720,6 +865,69 @@ func (siw *ServerInterfaceWrapper) ListReservationSlots(c *gin.Context) { siw.Handler.ListReservationSlots(c, params) } +// ListReservationTemplates operation middleware +func (siw *ServerInterfaceWrapper) ListReservationTemplates(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ListReservationTemplatesParams + + // ------------- Required query parameter "sport_type" ------------- + + if paramValue := c.Query("sport_type"); paramValue != "" { + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument sport_type is required, but not found"), http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameterWithOptions("form", true, true, "sport_type", c.Request.URL.Query(), ¶ms.SportType, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sport_type: %w", err), http.StatusBadRequest) + return + } + + // ------------- Required query parameter "campus_name" ------------- + + if paramValue := c.Query("campus_name"); paramValue != "" { + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument campus_name is required, but not found"), http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameterWithOptions("form", true, true, "campus_name", c.Request.URL.Query(), ¶ms.CampusName, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter campus_name: %w", err), http.StatusBadRequest) + return + } + + // ------------- Required query parameter "venue_name" ------------- + + if paramValue := c.Query("venue_name"); paramValue != "" { + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument venue_name is required, but not found"), http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameterWithOptions("form", true, true, "venue_name", c.Request.URL.Query(), ¶ms.VenueName, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter venue_name: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.ListReservationTemplates(c, params) +} + // ListReservationVenues operation middleware func (siw *ServerInterfaceWrapper) ListReservationVenues(c *gin.Context) { @@ -1095,6 +1303,30 @@ func (siw *ServerInterfaceWrapper) RejectJoinRequest(c *gin.Context) { siw.Handler.RejectJoinRequest(c, roomId) } +// CreateRoomReservationPlan operation middleware +func (siw *ServerInterfaceWrapper) CreateRoomReservationPlan(c *gin.Context) { + + var err error + + // ------------- Path parameter "roomId" ------------- + var roomId RoomIdPath + + err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "integer", Format: "int64"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomId: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.CreateRoomReservationPlan(c, roomId) +} + // PreviewRoomReservation operation middleware func (siw *ServerInterfaceWrapper) PreviewRoomReservation(c *gin.Context) { @@ -1210,6 +1442,8 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/auth/logout", wrapper.LogoutCurrentUser) router.POST(options.BaseURL+"/auth/wx/login", wrapper.LoginWithWechat) router.GET(options.BaseURL+"/healthz", wrapper.GetHealthz) + router.POST(options.BaseURL+"/internal/tasks/reservation-materialize", wrapper.TriggerReservationMaterialize) + router.POST(options.BaseURL+"/internal/tasks/reservation-trigger", wrapper.TriggerReservation) router.GET(options.BaseURL+"/me", wrapper.GetCurrentUser) router.PUT(options.BaseURL+"/me/profile", wrapper.UpdateCurrentUserProfile) router.GET(options.BaseURL+"/me/rooms/created", wrapper.ListMyCreatedRooms) @@ -1217,6 +1451,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/me/stats", wrapper.GetMyStats) router.GET(options.BaseURL+"/readyz", wrapper.GetReadyz) router.GET(options.BaseURL+"/reservations/slots", wrapper.ListReservationSlots) + router.GET(options.BaseURL+"/reservations/templates", wrapper.ListReservationTemplates) router.GET(options.BaseURL+"/reservations/venues", wrapper.ListReservationVenues) router.GET(options.BaseURL+"/rooms", wrapper.ListRooms) router.POST(options.BaseURL+"/rooms", wrapper.CreateRoom) @@ -1230,6 +1465,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/rooms/:roomId/join", wrapper.JoinRoomDirectly) router.POST(options.BaseURL+"/rooms/:roomId/members/:userId/remove", wrapper.RemoveRoomMember) router.POST(options.BaseURL+"/rooms/:roomId/reject", wrapper.RejectJoinRequest) + router.POST(options.BaseURL+"/rooms/:roomId/reservation/plan", wrapper.CreateRoomReservationPlan) router.POST(options.BaseURL+"/rooms/:roomId/reservation/preview", wrapper.PreviewRoomReservation) router.POST(options.BaseURL+"/rooms/:roomId/reservation/submit", wrapper.SubmitRoomReservation) router.POST(options.BaseURL+"/users", wrapper.CreateUser) diff --git a/models/room_reservation.go b/models/room_reservation.go index 91bbe52..2fb013e 100644 --- a/models/room_reservation.go +++ b/models/room_reservation.go @@ -16,6 +16,9 @@ type RoomReservation struct { VenueSiteID *uint SpaceID *uint SpaceName string `gorm:"size:64"` + TimeID *uint + Token string `gorm:"size:128"` + 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"` diff --git a/test.go b/test.go new file mode 100644 index 0000000..77ee92f --- /dev/null +++ b/test.go @@ -0,0 +1,102 @@ +//go:build ignore + +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "time" + + zjulogin "github.com/QSCTech/SRTP-Backend/internal/zjulogin" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + auth, err := zjulogin.NewFromEnv() + if err != nil { + panic(err) + } + tyys, err := auth.TYYS() + if err != nil { + panic(err) + } + + solver := zjulogin.TYYSPythonCaptchaSolver{ + PythonPath: firstNonEmpty(os.Getenv("TYYS_CAPTCHA_PYTHON"), "python"), + ScriptPath: firstNonEmpty(os.Getenv("TYYS_CAPTCHA_SCRIPT"), "scripts/tyys_captcha_solver.py"), + } + + reservationDate := "2026-04-25" + weekStartDate := reservationDate + venueID := "22" + venueSiteID := "143" + spaceID := "328" + timeID := "22013" + + dayInfoParams := url.Values{} + dayInfoParams.Set("venueId", venueID) + dayInfoParams.Set("venueSiteId", venueSiteID) + dayInfoParams.Set("siteId", venueSiteID) + dayInfoParams.Set("date", reservationDate) + dayInfoParams.Set("reservationDate", reservationDate) + dayInfoParams.Set("searchDate", reservationDate) + dayInfoParams.Set("weekStartDate", weekStartDate) + + dayInfo, err := tyys.ReservationDayInfo(ctx, dayInfoParams) + if err != nil { + panic(err) + } + + token, err := extractReservationToken(dayInfo.Data) + if err != nil { + panic(err) + } + + result, err := tyys.ReserveV2(ctx, zjulogin.TYYSReservationV2Request{ + ReservationDate: reservationDate, + WeekStartDate: weekStartDate, + Token: token, + VenueSiteID: venueSiteID, + SpaceID: spaceID, + TimeID: timeID, + BuddyCode: "", //同伴码 + Phone: "", + IsOfflineTicket: firstNonEmpty(os.Getenv("TYYS_IS_OFFLINE_TICKET"), "1"), + CaptchaSolver: solver, + }) + if err != nil { + panic(err) + } + + fmt.Printf("reservation_date=%s venue_site_id=%s space_id=%s time_id=%s token=%s\n", result.ReservationDate, result.VenueSiteID, result.SpaceID, result.TimeID, result.Token) + fmt.Printf("order form: %s\n", result.OrderForm.Encode()) + if result.Submit != nil { + fmt.Printf("submit code=%d message=%s\n", result.Submit.Code, result.Submit.Message) + } +} + +func extractReservationToken(data json.RawMessage) (string, error) { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return "", fmt.Errorf("parse day/info data: %w", err) + } + token, ok := payload["token"].(string) + if !ok || token == "" { + return "", fmt.Errorf("token not found in day/info response") + } + return token, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} From b57e137505358ba7e92e207a8939a570b972d1fc Mon Sep 17 00:00:00 2001 From: GP Date: Thu, 30 Apr 2026 12:07:58 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E5=9C=BA?= =?UTF-8?q?=E5=9C=B0=E5=A4=9A=E6=97=B6=E9=97=B4=E6=AE=B5=E9=A2=84=E7=BA=A6?= =?UTF-8?q?=EF=BC=8Cper-slot=20=E6=90=BA=E5=B8=A6=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:同学在约场地时(尤其是提前很久的 plan 模式)往往可以接受多个 时间段、多个场地,甚至多个校区。将这种灵活性直接编码进 API,避免在 预约时强制只选一个场地。 主要变更: 【API / OpenAPI】 - ReservationSlotSelection 新增 campus_name / venue_name / venue_id / start_time / end_time,每个 slot 自带完整上下文,支持跨场馆多选。 - ReservationSubmitRequest / PreviewInput 去除顶层冗余的 campus/venue/ start/end,改为从各 slot 中携带。 - 新增 ReservationPlanSlotSelection,ReservationPlanRequest 用 plan_slots[] 替代原 preferred_space_ids。 - Preview / Submit / Plan 三个端点改用 roomPublicId(UUID 路径参数), 符合 public_id 迁移方向。 【Service 层】 - SlotSelection 增加 campus/venue/start/end/venue_id 字段;trySlots 每次尝试前同步写回 record,DB 始终记录当前正在尝试的场地信息。 - ReservationPreviewInput / ReservationPlanInput 去除冗余顶层字段。 - PlanSlotSelection:完整的计划意图结构(相当于 SlotSelection 减去 materialize 时才能确定的 time_id / token / week_start)。 - CreatePlan:reserve_open_at 取所有 PlanSlots 中最早的预约窗口时间, 确保调度器在第一个场馆开窗时即可触发。 - resolvePreferredSlots:按 (campus, venue) 分组查 ListSlots,按 space_id 过滤,不做时间范围过滤,由 trySlots 逐个尝试。 【Handler 层】 - 补全 4 个缺失的 handler 方法:ListReservationTemplates、 CreateRoomReservationPlan、TriggerReservationMaterialize、 TriggerReservation。 - 所有对外接口一律用 public_id(UUID)查询房间和预约记录。 - 新增 RoomService.GetByPublicID stub。 【Model】 - RoomReservation.PreferredSpaceIDs 重命名为 PlanSlots,存储 []PlanSlotSelection 的 JSON 序列化。 Co-Authored-By: Claude Sonnet 4.6 --- api/openapi/openapi.yaml | 180 +++-- internal/api/gen/api.gen.go | 184 +++-- internal/api/handler.go | 216 +++++- internal/repository/reservation_repository.go | 33 + internal/service/reservation_service.go | 724 +++++++++++++++++- internal/service/room_service.go | 4 + models/room_reservation.go | 3 + 7 files changed, 1141 insertions(+), 203 deletions(-) diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 39c58a8..3e164c3 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -560,12 +560,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /rooms/{roomId}/reservation/preview: + /rooms/{roomPublicId}/reservation/preview: post: operationId: previewRoomReservation summary: Preview reservation for a room parameters: - - $ref: '#/components/parameters/RoomIdPath' + - $ref: '#/components/parameters/RoomPublicIdPath' requestBody: required: true content: @@ -585,12 +585,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /rooms/{roomId}/reservation/submit: + /rooms/{roomPublicId}/reservation/submit: post: operationId: submitRoomReservation summary: Submit reservation for a room parameters: - - $ref: '#/components/parameters/RoomIdPath' + - $ref: '#/components/parameters/RoomPublicIdPath' requestBody: required: true content: @@ -646,15 +646,15 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /rooms/{roomId}/reservation/plan: + /rooms/{roomPublicId}/reservation/plan: post: operationId: createRoomReservationPlan summary: Create a reservation plan for a future date (>2 days) description: | 为 2 天后的日期创建预约计划,保存预约意图到数据库。 - 系统会在预约日期前 2 天的上午 09:00 自动补全 slot 上下文并提交 TYYS。 + 系统会在预约日期前 2 天的相应时间 自动补全 slot 上下文并提交 TYYS。 parameters: - - $ref: '#/components/parameters/RoomIdPath' + - $ref: '#/components/parameters/RoomPublicIdPath' requestBody: required: true content: @@ -744,6 +744,13 @@ components: type: integer format: int64 minimum: 1 + RoomPublicIdPath: + in: path + name: roomPublicId + required: true + schema: + type: string + format: uuid MemberUserIdPath: in: path name: userId @@ -1261,7 +1268,7 @@ components: description: 是否可预约 space_name: type: string - description: 场地名称(如羽毛球场1号场) + description: 场地名称(如游泳馆免费场) ReservationSlotListResponse: type: object required: [items] @@ -1270,30 +1277,66 @@ components: type: array items: $ref: '#/components/schemas/ReservationSlot' + ReservationSlotSelection: + type: object + required: [campus_name, venue_name, venue_site_id, space_id, start_time, end_time, time_id, token] + description: 单个候选场地时间段,由前端从 /reservations/slots 结果中选取后透传 + properties: + campus_name: + type: string + venue_name: + type: string + venue_id: + type: integer + format: int64 + venue_site_id: + type: integer + format: int64 + space_id: + type: integer + format: int64 + space_name: + type: string + start_time: + type: string + end_time: + type: string + time_id: + type: integer + format: int64 + token: + type: string + description: TYYS slot token + week_start_date: + type: string + format: date ReservationSubmitRequest: type: object required: - sport_type - - campus_name - - venue_name - reservation_date - - start_time - - end_time + - slots properties: sport_type: type: string - campus_name: - type: string - venue_name: - type: string reservation_date: type: string format: date - start_time: + buddy_code: type: string - end_time: + slots: + type: array + items: + $ref: '#/components/schemas/ReservationSlotSelection' + description: 候选场地列表,campus/venue/start/end 等上下文均在每个 slot 中携带 + ReservationPreviewItem: + type: object + required: [campus_name, venue_name, venue_site_id, space_id, start_time, end_time, time_id, token, available] + description: 单个 slot 的预览结果 + properties: + campus_name: type: string - buddy_code: + venue_name: type: string venue_id: type: integer @@ -1306,30 +1349,33 @@ components: format: int64 space_name: type: string + start_time: + type: string + end_time: + type: string time_id: type: integer format: int64 - description: TYYS 时间段ID,实时预约必填 token: type: string - description: TYYS slot token,实时预约必填 week_start_date: type: string format: date - description: TYYS 周起始日期,默认取 reservation_date + available: + type: boolean + description: 该 slot 是否通过了 TYYS orderInfo 校验 + error: + type: string + description: 校验失败时的错误信息 ReservationPreviewResponse: type: object required: - room_id - room_public_id - provider - - reservation_status - sport_type - - campus_name - - venue_name - reservation_date - - start_time - - end_time + - slots properties: room_id: type: integer @@ -1339,34 +1385,18 @@ components: format: uuid provider: type: string - reservation_status: - type: string sport_type: type: string - campus_name: - type: string - venue_name: - type: string reservation_date: type: string format: date - start_time: - type: string - end_time: - type: string buddy_code: type: string - venue_id: - type: integer - format: int64 - venue_site_id: - type: integer - format: int64 - space_id: - type: integer - format: int64 - space_name: - type: string + slots: + type: array + items: + $ref: '#/components/schemas/ReservationPreviewItem' + description: 每个候选 slot 的 TYYS orderInfo 校验结果 ReservationRecordResponse: type: object required: @@ -1487,42 +1517,58 @@ components: type: array items: $ref: '#/components/schemas/ReservationTemplateTimeSlot' + ReservationPlanSlotSelection: + type: object + required: [campus_name, venue_name, space_id, start_time, end_time] + description: 计划路径首选场次,与 ReservationSlotSelection 相比不含需要 materialize 时才能确定的 time_id/token + properties: + campus_name: + type: string + venue_name: + type: string + venue_id: + type: integer + format: int64 + venue_site_id: + type: integer + format: int64 + space_id: + type: integer + format: int64 + space_name: + type: string + start_time: + type: string + end_time: + type: string ReservationPlanRequest: type: object required: - sport_type - - campus_name - - venue_name - reservation_date - - start_time - - end_time + - plan_slots description: 创建预约计划请求(仅保存预约意图,不立即调 TYYS) properties: sport_type: type: string - campus_name: - type: string - venue_name: - type: string reservation_date: type: string format: date - start_time: - type: string - example: "08:00" - end_time: - type: string - example: "09:00" buddy_code: type: string + plan_slots: + type: array + items: + $ref: '#/components/schemas/ReservationPlanSlotSelection' + description: 选中场次列表,campus/venue/start/end 均在每个 slot 中携带;调度器依次尝试直到一个成功 ReservationTriggerRequest: type: object - required: [reservation_id] - description: 触发单条预约提交(供调度器调用) + required: [reservation_public_id] + description: 触发单条预约提交(供调度器调用,使用 public_id 避免整数枚举越权风险) properties: - reservation_id: - type: integer - format: int64 + reservation_public_id: + type: string + format: uuid ReservationMaterializeRequest: type: object description: | diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index 48216cb..3501695 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -107,32 +107,57 @@ type ReservationMaterializeResult struct { // ReservationPlanRequest 创建预约计划请求(仅保存预约意图,不立即调 TYYS) type ReservationPlanRequest struct { - BuddyCode *string `json:"buddy_code,omitempty"` - CampusName string `json:"campus_name"` - EndTime string `json:"end_time"` - ReservationDate openapi_types.Date `json:"reservation_date"` - SportType string `json:"sport_type"` - StartTime string `json:"start_time"` - VenueName string `json:"venue_name"` + BuddyCode *string `json:"buddy_code,omitempty"` + + // PlanSlots 首选场次列表,campus/venue/start/end 均在每个 slot 中携带;调度器依次尝试直到一个成功 + PlanSlots []ReservationPlanSlotSelection `json:"plan_slots"` + ReservationDate openapi_types.Date `json:"reservation_date"` + SportType string `json:"sport_type"` +} + +// ReservationPlanSlotSelection 计划路径首选场次,与 ReservationSlotSelection 相比不含需要 materialize 时才能确定的 time_id/token +type ReservationPlanSlotSelection struct { + CampusName string `json:"campus_name"` + EndTime string `json:"end_time"` + SpaceId int64 `json:"space_id"` + SpaceName *string `json:"space_name,omitempty"` + StartTime string `json:"start_time"` + VenueId *int64 `json:"venue_id,omitempty"` + VenueName string `json:"venue_name"` + VenueSiteId *int64 `json:"venue_site_id,omitempty"` +} + +// ReservationPreviewItem 单个 slot 的预览结果 +type ReservationPreviewItem struct { + // Available 该 slot 是否通过了 TYYS orderInfo 校验 + Available bool `json:"available"` + CampusName string `json:"campus_name"` + EndTime string `json:"end_time"` + + // Error 校验失败时的错误信息 + Error *string `json:"error,omitempty"` + SpaceId int64 `json:"space_id"` + SpaceName *string `json:"space_name,omitempty"` + StartTime string `json:"start_time"` + TimeId int64 `json:"time_id"` + Token string `json:"token"` + VenueId *int64 `json:"venue_id,omitempty"` + VenueName string `json:"venue_name"` + VenueSiteId int64 `json:"venue_site_id"` + WeekStartDate *openapi_types.Date `json:"week_start_date,omitempty"` } // 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"` - RoomPublicId openapi_types.UUID `json:"room_public_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"` + Provider string `json:"provider"` + ReservationDate openapi_types.Date `json:"reservation_date"` + RoomId int64 `json:"room_id"` + RoomPublicId openapi_types.UUID `json:"room_public_id"` + + // Slots 每个候选 slot 的 TYYS orderInfo 校验结果 + Slots []ReservationPreviewItem `json:"slots"` + SportType string `json:"sport_type"` } // ReservationRecordResponse defines model for ReservationRecordResponse. @@ -171,7 +196,7 @@ type ReservationSlot struct { // SlotKey 时间段唯一标识,用于提交预约 SlotKey string `json:"slot_key"` - // SpaceName 场地名称(如羽毛球场1号场) + // SpaceName 场地名称(如游泳馆免费场) SpaceName *string `json:"space_name,omitempty"` // StartTime 开始时间,格式 HH:mm 或 YYYY-MM-DD HH:mm @@ -183,28 +208,31 @@ type ReservationSlotListResponse struct { Items []ReservationSlot `json:"items"` } +// ReservationSlotSelection 单个候选场地时间段,由前端从 /reservations/slots 结果中选取后透传 +type ReservationSlotSelection struct { + CampusName string `json:"campus_name"` + EndTime string `json:"end_time"` + SpaceId int64 `json:"space_id"` + SpaceName *string `json:"space_name,omitempty"` + StartTime string `json:"start_time"` + TimeId int64 `json:"time_id"` + + // Token TYYS slot token + Token string `json:"token"` + VenueId *int64 `json:"venue_id,omitempty"` + VenueName string `json:"venue_name"` + VenueSiteId int64 `json:"venue_site_id"` + WeekStartDate *openapi_types.Date `json:"week_start_date,omitempty"` +} + // ReservationSubmitRequest defines model for ReservationSubmitRequest. type ReservationSubmitRequest struct { BuddyCode *string `json:"buddy_code,omitempty"` - CampusName string `json:"campus_name"` - EndTime string `json:"end_time"` ReservationDate openapi_types.Date `json:"reservation_date"` - SpaceId *int64 `json:"space_id,omitempty"` - SpaceName *string `json:"space_name,omitempty"` - SportType string `json:"sport_type"` - StartTime string `json:"start_time"` - - // TimeId TYYS 时间段ID,实时预约必填 - TimeId *int64 `json:"time_id,omitempty"` - // Token TYYS slot token,实时预约必填 - Token *string `json:"token,omitempty"` - VenueId *int64 `json:"venue_id,omitempty"` - VenueName string `json:"venue_name"` - VenueSiteId *int64 `json:"venue_site_id,omitempty"` - - // WeekStartDate TYYS 周起始日期,默认取 reservation_date - WeekStartDate *openapi_types.Date `json:"week_start_date,omitempty"` + // Slots 候选场地列表,campus/venue/start/end 等上下文均在每个 slot 中携带 + Slots []ReservationSlotSelection `json:"slots"` + SportType string `json:"sport_type"` } // ReservationTemplateResponse 场馆固定结构信息,用于创建预约计划时选择时间段 @@ -232,9 +260,9 @@ type ReservationTemplateTimeSlot struct { TimeId *int64 `json:"time_id,omitempty"` } -// ReservationTriggerRequest 触发单条预约提交(供调度器调用) +// ReservationTriggerRequest 触发单条预约提交(供调度器调用,使用 public_id 避免整数枚举越权风险) type ReservationTriggerRequest struct { - ReservationId int64 `json:"reservation_id"` + ReservationPublicId openapi_types.UUID `json:"reservation_public_id"` } // ReservationVenue 场馆信息 @@ -359,15 +387,18 @@ 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"` - ProfileStatus string `json:"profile_status"` - UpdatedAt time.Time `json:"updated_at"` + 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"` + PublicId openapi_types.UUID `json:"public_id"` + UpdatedAt time.Time `json:"updated_at"` } // UserResponse defines model for UserResponse. @@ -398,6 +429,9 @@ type PageSizeQuery = int32 // RoomIdPath defines model for RoomIdPath. type RoomIdPath = int64 +// RoomPublicIdPath defines model for RoomPublicIdPath. +type RoomPublicIdPath = openapi_types.UUID + // UserIdPath defines model for UserIdPath. type UserIdPath = int64 @@ -583,14 +617,14 @@ type ServerInterface interface { // (POST /rooms/{roomId}/reject) RejectJoinRequest(c *gin.Context, roomId RoomIdPath) // Create a reservation plan for a future date (>2 days) - // (POST /rooms/{roomId}/reservation/plan) - CreateRoomReservationPlan(c *gin.Context, roomId RoomIdPath) + // (POST /rooms/{roomPublicId}/reservation/plan) + CreateRoomReservationPlan(c *gin.Context, roomPublicId RoomPublicIdPath) // Preview reservation for a room - // (POST /rooms/{roomId}/reservation/preview) - PreviewRoomReservation(c *gin.Context, roomId RoomIdPath) + // (POST /rooms/{roomPublicId}/reservation/preview) + PreviewRoomReservation(c *gin.Context, roomPublicId RoomPublicIdPath) // Submit reservation for a room - // (POST /rooms/{roomId}/reservation/submit) - SubmitRoomReservation(c *gin.Context, roomId RoomIdPath) + // (POST /rooms/{roomPublicId}/reservation/submit) + SubmitRoomReservation(c *gin.Context, roomPublicId RoomPublicIdPath) // Create a user record // (POST /users) CreateUser(c *gin.Context) @@ -1314,12 +1348,12 @@ func (siw *ServerInterfaceWrapper) CreateRoomReservationPlan(c *gin.Context) { var err error - // ------------- Path parameter "roomId" ------------- - var roomId RoomIdPath + // ------------- Path parameter "roomPublicId" ------------- + var roomPublicId RoomPublicIdPath - err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "integer", Format: "int64"}) + err = runtime.BindStyledParameterWithOptions("simple", "roomPublicId", c.Param("roomPublicId"), &roomPublicId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomId: %w", err), http.StatusBadRequest) + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomPublicId: %w", err), http.StatusBadRequest) return } @@ -1330,7 +1364,7 @@ func (siw *ServerInterfaceWrapper) CreateRoomReservationPlan(c *gin.Context) { } } - siw.Handler.CreateRoomReservationPlan(c, roomId) + siw.Handler.CreateRoomReservationPlan(c, roomPublicId) } // PreviewRoomReservation operation middleware @@ -1338,12 +1372,12 @@ func (siw *ServerInterfaceWrapper) PreviewRoomReservation(c *gin.Context) { var err error - // ------------- Path parameter "roomId" ------------- - var roomId RoomIdPath + // ------------- Path parameter "roomPublicId" ------------- + var roomPublicId RoomPublicIdPath - err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "integer", Format: "int64"}) + err = runtime.BindStyledParameterWithOptions("simple", "roomPublicId", c.Param("roomPublicId"), &roomPublicId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomId: %w", err), http.StatusBadRequest) + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomPublicId: %w", err), http.StatusBadRequest) return } @@ -1354,7 +1388,7 @@ func (siw *ServerInterfaceWrapper) PreviewRoomReservation(c *gin.Context) { } } - siw.Handler.PreviewRoomReservation(c, roomId) + siw.Handler.PreviewRoomReservation(c, roomPublicId) } // SubmitRoomReservation operation middleware @@ -1362,12 +1396,12 @@ func (siw *ServerInterfaceWrapper) SubmitRoomReservation(c *gin.Context) { var err error - // ------------- Path parameter "roomId" ------------- - var roomId RoomIdPath + // ------------- Path parameter "roomPublicId" ------------- + var roomPublicId RoomPublicIdPath - err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "integer", Format: "int64"}) + err = runtime.BindStyledParameterWithOptions("simple", "roomPublicId", c.Param("roomPublicId"), &roomPublicId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomId: %w", err), http.StatusBadRequest) + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomPublicId: %w", err), http.StatusBadRequest) return } @@ -1378,7 +1412,7 @@ func (siw *ServerInterfaceWrapper) SubmitRoomReservation(c *gin.Context) { } } - siw.Handler.SubmitRoomReservation(c, roomId) + siw.Handler.SubmitRoomReservation(c, roomPublicId) } // CreateUser operation middleware @@ -1471,9 +1505,9 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/rooms/:roomId/join", wrapper.JoinRoomDirectly) router.POST(options.BaseURL+"/rooms/:roomId/members/:userId/remove", wrapper.RemoveRoomMember) router.POST(options.BaseURL+"/rooms/:roomId/reject", wrapper.RejectJoinRequest) - router.POST(options.BaseURL+"/rooms/:roomId/reservation/plan", wrapper.CreateRoomReservationPlan) - router.POST(options.BaseURL+"/rooms/:roomId/reservation/preview", wrapper.PreviewRoomReservation) - router.POST(options.BaseURL+"/rooms/:roomId/reservation/submit", wrapper.SubmitRoomReservation) + router.POST(options.BaseURL+"/rooms/:roomPublicId/reservation/plan", wrapper.CreateRoomReservationPlan) + router.POST(options.BaseURL+"/rooms/:roomPublicId/reservation/preview", wrapper.PreviewRoomReservation) + router.POST(options.BaseURL+"/rooms/:roomPublicId/reservation/submit", wrapper.SubmitRoomReservation) router.POST(options.BaseURL+"/users", wrapper.CreateUser) router.GET(options.BaseURL+"/users/:id", wrapper.GetUserById) } diff --git a/internal/api/handler.go b/internal/api/handler.go index 31a142e..bd74603 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -380,37 +380,141 @@ func (h *Handler) ListReservationSlots(c *gin.Context, params gen.ListReservatio response.JSON(c, http.StatusOK, resp) } -func (h *Handler) PreviewRoomReservation(c *gin.Context, roomId int64) { +func (h *Handler) PreviewRoomReservation(c *gin.Context, roomPublicId gen.RoomPublicIdPath) { var req gen.ReservationSubmitRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "invalid request body") return } - preview, err := h.reservationService.Preview(c.Request.Context(), buildReservationPreviewInput(uint(roomId), req)) + room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomPublicId.String()) + if roomErr != nil { + response.Error(c, http.StatusNotFound, "room not found") + return + } + preview, err := h.reservationService.Preview(c.Request.Context(), buildReservationPreviewInput(room.ID, req)) if err != nil { response.Error(c, http.StatusBadRequest, err.Error()) return } - room, _, roomErr := h.roomService.GetByID(c.Request.Context(), uint(roomId)) + response.JSON(c, http.StatusOK, buildReservationPreviewResponse(preview, room.PublicID)) +} + +func (h *Handler) SubmitRoomReservation(c *gin.Context, roomPublicId gen.RoomPublicIdPath) { + var req gen.ReservationSubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request body") + return + } + room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomPublicId.String()) if roomErr != nil { - response.Error(c, http.StatusInternalServerError, "failed to fetch room") + response.Error(c, http.StatusNotFound, "room not found") return } - response.JSON(c, http.StatusOK, buildReservationPreviewResponse(preview, room.PublicID)) + reservation, err := h.reservationService.Submit(c.Request.Context(), buildReservationPreviewInput(room.ID, req)) + if err != nil { + response.Error(c, http.StatusBadRequest, err.Error()) + return + } + response.JSON(c, http.StatusOK, buildReservationRecordResponse(reservation, room.PublicID)) } -func (h *Handler) SubmitRoomReservation(c *gin.Context, roomId int64) { - var req gen.ReservationSubmitRequest +func (h *Handler) ListReservationTemplates(c *gin.Context, params gen.ListReservationTemplatesParams) { + out, err := h.reservationService.ListTemplates(c.Request.Context(), params.SportType, params.CampusName, params.VenueName) + if err != nil { + response.Error(c, http.StatusBadRequest, err.Error()) + return + } + spaces := make([]gen.ReservationTemplateSpace, 0, len(out.Spaces)) + for _, sp := range out.Spaces { + spaces = append(spaces, gen.ReservationTemplateSpace{SpaceId: uint64ToInt64(sp.SpaceID), SpaceName: sp.SpaceName}) + } + timeSlots := make([]gen.ReservationTemplateTimeSlot, 0, len(out.TimeSlots)) + for _, ts := range out.TimeSlots { + timeSlots = append(timeSlots, gen.ReservationTemplateTimeSlot{ + TimeId: uintPtrToInt64Ptr(ts.TimeID), + StartTime: ts.StartTime, + EndTime: ts.EndTime, + }) + } + response.JSON(c, http.StatusOK, gen.ReservationTemplateResponse{ + SportType: out.SportType, + CampusName: out.CampusName, + VenueName: out.VenueName, + VenueId: uintPtrToInt64Ptr(out.VenueID), + Spaces: spaces, + TimeSlots: timeSlots, + }) +} + +func (h *Handler) CreateRoomReservationPlan(c *gin.Context, roomPublicId gen.RoomPublicIdPath) { + var req gen.ReservationPlanRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "invalid request body") return } - reservation, err := h.reservationService.Submit(c.Request.Context(), buildReservationPreviewInput(uint(roomId), req)) + room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomPublicId.String()) + if roomErr != nil { + response.Error(c, http.StatusNotFound, "room not found") + return + } + planSlots := make([]service.PlanSlotSelection, 0, len(req.PlanSlots)) + for _, ps := range req.PlanSlots { + planSlots = append(planSlots, service.PlanSlotSelection{ + CampusName: ps.CampusName, + VenueName: ps.VenueName, + VenueID: int64PtrToUintPtr(ps.VenueId), + VenueSiteID: uint64PtrToUintPtr(ps.VenueSiteId), + SpaceID: uint(ps.SpaceId), + SpaceName: ps.SpaceName, + StartTime: ps.StartTime, + EndTime: ps.EndTime, + }) + } + reservation, err := h.reservationService.CreatePlan(c.Request.Context(), service.ReservationPlanInput{ + RoomID: room.ID, + SportType: req.SportType, + ReservationDate: req.ReservationDate.String(), + BuddyCode: req.BuddyCode, + PlanSlots: planSlots, + }) if err != nil { response.Error(c, http.StatusBadRequest, err.Error()) return } - room, _, roomErr := h.roomService.GetByID(c.Request.Context(), uint(roomId)) + response.JSON(c, http.StatusOK, buildReservationRecordResponse(reservation, room.PublicID)) +} + +func (h *Handler) TriggerReservationMaterialize(c *gin.Context) { + var req gen.ReservationMaterializeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request body") + return + } + dryRun := req.DryRun != nil && *req.DryRun + result := h.reservationService.MaterializePlans(c.Request.Context(), dryRun) + resp := gen.ReservationMaterializeResult{ + Total: result.Total, + Succeeded: result.Succeeded, + Failed: result.Failed, + } + if len(result.Errors) > 0 { + resp.Errors = &result.Errors + } + response.JSON(c, http.StatusOK, resp) +} + +func (h *Handler) TriggerReservation(c *gin.Context) { + var req gen.ReservationTriggerRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request body") + return + } + reservation, err := h.reservationService.TriggerReservation(c.Request.Context(), req.ReservationPublicId.String()) + if err != nil { + response.Error(c, http.StatusBadRequest, err.Error()) + return + } + room, _, roomErr := h.roomService.GetByID(c.Request.Context(), reservation.RoomID) if roomErr != nil { response.Error(c, http.StatusInternalServerError, "failed to fetch room") return @@ -493,39 +597,75 @@ func buildJoinRoomResult(result *service.JoinRoomOutput) gen.JoinRoomResult { } func buildReservationPreviewInput(roomID uint, req gen.ReservationSubmitRequest) service.ReservationPreviewInput { + var slots []service.SlotSelection + for _, s := range req.Slots { + var weekStart string + if s.WeekStartDate != nil { + weekStart = s.WeekStartDate.String() + } + slots = append(slots, service.SlotSelection{ + CampusName: s.CampusName, + VenueName: s.VenueName, + VenueID: int64PtrToUintPtr(s.VenueId), + VenueSiteID: uint(s.VenueSiteId), + SpaceID: uint(s.SpaceId), + SpaceName: s.SpaceName, + StartTime: s.StartTime, + EndTime: s.EndTime, + TimeID: uint(s.TimeId), + Token: s.Token, + WeekStart: weekStart, + }) + } return service.ReservationPreviewInput{ RoomID: roomID, SportType: req.SportType, - CampusName: req.CampusName, - VenueName: req.VenueName, ReservationDate: req.ReservationDate.String(), - StartTime: req.StartTime, - EndTime: req.EndTime, BuddyCode: req.BuddyCode, - VenueID: int64PtrToUintPtr(req.VenueId), - VenueSiteID: int64PtrToUintPtr(req.VenueSiteId), - SpaceID: int64PtrToUintPtr(req.SpaceId), - SpaceName: req.SpaceName, + Slots: slots, } } +func derefSlotSlice(slots *[]gen.ReservationSlotSelection) []gen.ReservationSlotSelection { + if slots == nil { + return nil + } + return *slots +} + func buildReservationPreviewResponse(preview *service.ReservationPreviewOutput, roomPublicID string) gen.ReservationPreviewResponse { + items := make([]gen.ReservationPreviewItem, 0, len(preview.Slots)) + for _, s := range preview.Slots { + item := gen.ReservationPreviewItem{ + CampusName: s.Slot.CampusName, + VenueName: s.Slot.VenueName, + VenueId: uintPtrToInt64Ptr(s.Slot.VenueID), + VenueSiteId: int64(s.Slot.VenueSiteID), + SpaceId: int64(s.Slot.SpaceID), + SpaceName: s.Slot.SpaceName, + StartTime: s.Slot.StartTime, + EndTime: s.Slot.EndTime, + TimeId: int64(s.Slot.TimeID), + Token: s.Slot.Token, + Available: s.Available, + } + if s.Error != "" { + item.Error = &s.Error + } + if s.Slot.WeekStart != "" { + d := openapi_types.Date{Time: mustParseDate(s.Slot.WeekStart)} + item.WeekStartDate = &d + } + items = append(items, item) + } return gen.ReservationPreviewResponse{ - RoomId: int64(preview.RoomID), - RoomPublicId: mustParseUUID(roomPublicID), - Provider: preview.Provider, - ReservationStatus: preview.ReservationStatus, - SportType: preview.SportType, - CampusName: preview.CampusName, - VenueName: preview.VenueName, - ReservationDate: openapi_types.Date{Time: mustParseDate(preview.ReservationDate)}, - StartTime: preview.StartTime, - EndTime: preview.EndTime, - BuddyCode: preview.BuddyCode, - VenueId: uintPtrToInt64Ptr(preview.VenueID), - VenueSiteId: uintPtrToInt64Ptr(preview.VenueSiteID), - SpaceId: uintPtrToInt64Ptr(preview.SpaceID), - SpaceName: preview.SpaceName, + RoomId: int64(preview.RoomID), + RoomPublicId: mustParseUUID(roomPublicID), + Provider: preview.Provider, + SportType: preview.SportType, + ReservationDate: openapi_types.Date{Time: mustParseDate(preview.ReservationDate)}, + BuddyCode: preview.BuddyCode, + Slots: items, } } @@ -615,6 +755,18 @@ func uintPtrToInt64Ptr(value *uint) *int64 { return &v } +func uint64ToInt64(v uint) int64 { + return int64(v) +} + +func uint64PtrToUintPtr(value *int64) *uint { + if value == nil { + return nil + } + v := uint(*value) + return &v +} + func mustParseDate(value string) time.Time { t, err := time.Parse("2006-01-02", value) if err != nil { diff --git a/internal/repository/reservation_repository.go b/internal/repository/reservation_repository.go index a1a7216..2910561 100644 --- a/internal/repository/reservation_repository.go +++ b/internal/repository/reservation_repository.go @@ -2,6 +2,8 @@ package repository import ( "context" + "fmt" + "time" "github.com/QSCTech/SRTP-Backend/models" "gorm.io/gorm" @@ -46,3 +48,34 @@ func (r *ReservationRepository) GetByPublicID(ctx context.Context, publicID stri } return &reservation, nil } + +// Update 全量保存预约记录(用于补全 slot 上下文或回写执行结果)。 +func (r *ReservationRepository) Update(ctx context.Context, reservation *models.RoomReservation) error { + return r.db.WithContext(ctx).Save(reservation).Error +} + +// ListDueScheduled 返回所有 status=scheduled 且 reserve_open_at <= now 的计划, +// 供 materialize 调度器判断哪些计划已到开放时间。 +func (r *ReservationRepository) ListDueScheduled(ctx context.Context, now time.Time) ([]*models.RoomReservation, error) { + var reservations []*models.RoomReservation + err := r.db.WithContext(ctx). + Where("reservation_status = ? AND reserve_open_at <= ?", "scheduled", now). + Find(&reservations).Error + return reservations, err +} + +// AtomicTransitionStatus 原子地将记录从 fromStatus 切换到 toStatus,防止并发双触发。 +// 使用 public_id 定位记录,避免整数 ID 在内部流转。 +// 返回 false 表示记录已被其他执行者抢占,调用方应直接放弃本次执行。 +func (r *ReservationRepository) AtomicTransitionStatus(ctx context.Context, publicID, fromStatus, toStatus string) (bool, error) { + result := r.db.WithContext(ctx).Model(&models.RoomReservation{}). + Where("public_id = ? AND reservation_status = ?", publicID, fromStatus). + Update("reservation_status", toStatus) + if result.Error != nil { + return false, result.Error + } + if result.RowsAffected == 0 { + return false, fmt.Errorf("reservation %q not in status %q, may already be processing", publicID, fromStatus) + } + return true, nil +} diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 54b491c..d622097 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -7,6 +7,8 @@ import ( "net/url" "strconv" "strings" + "sync" + "time" "github.com/QSCTech/SRTP-Backend/internal/repository" "github.com/QSCTech/SRTP-Backend/internal/zjulogin" @@ -25,38 +27,111 @@ type ReservationSlotItem struct { EndTime string Available bool SpaceName *string + // Internal fields populated from dayInfo response; not exposed via API. + VenueSiteID uint + SpaceID uint + TimeID uint + Token string + WeekStart string +} + +// SlotSelection 是单个候选场地时间段,实时路径和计划路径共用。 +// 包含提交 TYYS 所需的执行上下文,以及写回 record 的 campus/venue/start/end 信息。 +type SlotSelection struct { + CampusName string + VenueName string + VenueID *uint + VenueSiteID uint + SpaceID uint + SpaceName *string + StartTime string + EndTime string + TimeID uint + Token string + WeekStart string +} + +// TemplateSpace 是场馆固定分场信息。 +type TemplateSpace struct { + SpaceID uint + SpaceName string +} + +// TemplateTimeSlot 是场馆固定时间段模板。 +type TemplateTimeSlot struct { + TimeID *uint + StartTime string + EndTime string + DisplayLabel string +} + +// ReservationTemplateOutput 是场馆固定结构信息,不依赖 TYYS 实时查询窗口。 +type ReservationTemplateOutput struct { + SportType string + CampusName string + VenueName string + VenueID *uint + VenueSiteID *uint + Spaces []TemplateSpace + TimeSlots []TemplateTimeSlot } type ReservationPreviewInput struct { RoomID uint SportType string - CampusName string - VenueName string ReservationDate string - StartTime string - EndTime string BuddyCode *string - VenueID *uint - VenueSiteID *uint - SpaceID *uint - SpaceName *string + // Slots 是前端从 /reservations/slots 中选取的候选场地列表,campus/venue/start/end 均在每个 slot 中携带。 + Slots []SlotSelection +} + +// PlanSlotSelection 是用户创建远期计划时指定的首选场次,供调度器在预约窗口期补全上下文。 +// 与 SlotSelection 相比,不含需要 materialize 时才能确定的 TimeID/Token/WeekStart。 +// CampusName/VenueName/StartTime/EndTime 若为空则继承计划顶层字段,允许跨场馆多选。 +type PlanSlotSelection struct { + CampusName string + VenueName string + VenueID *uint + VenueSiteID *uint + SpaceID uint + SpaceName *string + StartTime string + EndTime string +} + +// ReservationPlanInput 仅含预约意图,不需要实时 slot 上下文。 +// campus/venue/start/end 均在 PlanSlots 中按场次携带,顶层只保留跨 slot 共有的字段。 +type ReservationPlanInput struct { + RoomID uint + SportType string + ReservationDate string + BuddyCode *string + // PlanSlots 是用户指定的首选场次列表,每个 slot 携带完整的 campus/venue/start/end 信息。 + PlanSlots []PlanSlotSelection +} + +// SlotPreviewItem 是单个 slot 经 TYYS orderInfo 校验后的结果。 +type SlotPreviewItem struct { + Slot SlotSelection + Available bool + Error string } type ReservationPreviewOutput struct { - RoomID uint - Provider string - ReservationStatus string - SportType string - CampusName string - VenueName string - ReservationDate string - StartTime string - EndTime string - BuddyCode *string - VenueID *uint - VenueSiteID *uint - SpaceID *uint - SpaceName *string + RoomID uint + Provider string + SportType string + ReservationDate string + BuddyCode *string + Slots []SlotPreviewItem +} + +// MaterializeResult 是 materialize 批量执行的汇总结果。 +type MaterializeResult struct { + Total int + Succeeded int + Failed int + Errors []string } type ReservationService struct { @@ -203,14 +278,28 @@ 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: Extract top-level token/weekStartDate from dayInfo (not per-slot). + var topToken, topWeekStart string + var topObj map[string]any + if unmarshalErr := json.Unmarshal(dayResp.Data, &topObj); unmarshalErr == nil { + topToken = trimString(topObj["token"]) + topWeekStart = trimString(topObj["weekStartDate"]) + } + venueSiteIDUint := parseUint(venueSiteID) + + // Step 4: Parse slots, carrying internal IDs for use by materializeOne. 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), + SlotKey: trimString(slot["timeId"]), + StartTime: trimString(slot["startDate"]), + EndTime: trimString(slot["endDate"]), + Available: isSlotAvailable(slot), + VenueSiteID: venueSiteIDUint, + SpaceID: parseUint(trimString(slot["spaceId"])), + TimeID: parseUint(trimString(slot["timeId"])), + Token: coalesce(trimString(slot["token"]), topToken), + WeekStart: coalesce(trimString(slot["weekStartDate"]), topWeekStart), } if name := trimString(slot["spaceName"]); name != "" { item.SpaceName = &name @@ -249,10 +338,587 @@ func walkSlots(data []byte, visit func(map[string]any)) { }) } +// venueOpenHour 返回指定校区+球类的 TYYS 预约开放小时(Asia/Shanghai)。 +// 依据公共体育与艺术部 2024-10-17 公告,其余一律 09:00: +// - 紫金港 + 网球: 08:00 +// - 玉泉 + 羽毛球: 12:00 +// - 华家池 + 羽毛球: 18:00 +// - 华家池 + 网球: 18:00(膜顶网球场) +// +// 注意:创建预约计划时尚不知道具体分场(spaceName),因此只能按校区+球类推断开放时间。 +// 同一校区+球类下可能存在多个场馆,开放时间以该类型场馆中最早的为准。 +// 如果后续需要按具体场馆细化,务必同时调整 reserveOpenAt 的调用方也传入 venueName。 +func venueOpenHour(campusName, sportType string) int { + switch { + case strings.Contains(campusName, "紫金港") && strings.Contains(sportType, "网球"): + return 8 + case strings.Contains(campusName, "玉泉") && strings.Contains(sportType, "羽毛球"): + return 12 + case strings.Contains(campusName, "华家池") && strings.Contains(sportType, "羽毛球"): + return 18 + case strings.Contains(campusName, "华家池") && strings.Contains(sportType, "网球"): + return 18 + default: + return 9 + } +} + +// reserveOpenAt 计算给定预约日期、校区、球类对应的 TYYS 开放时间点(预约日期前2天)。 +func reserveOpenAt(reservationDate, campusName, sportType string) (time.Time, error) { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.UTC + } + date, err := time.ParseInLocation("2006-01-02", reservationDate, loc) + if err != nil { + return time.Time{}, fmt.Errorf("invalid reservation_date %q: %w", reservationDate, err) + } + openDate := date.AddDate(0, 0, -2) + hour := venueOpenHour(campusName, sportType) + return time.Date(openDate.Year(), openDate.Month(), openDate.Day(), hour, 0, 0, 0, loc), nil +} + +// ListTemplates 查询场馆固定结构信息(分场列表和时间段模板)。 +// 先用 VenueInfo 取分场,再尝试用明日的 dayInfo 取时间段模板;dayInfo 失败时只返回分场。 +func (s *ReservationService) ListTemplates(ctx context.Context, sportType, campusName, venueName string) (*ReservationTemplateOutput, error) { + venueResp, err := s.tyys.VenueInfo(ctx, 0) + if err != nil { + return nil, fmt.Errorf("get venue info: %w", err) + } + + out := &ReservationTemplateOutput{ + SportType: sportType, + CampusName: campusName, + VenueName: venueName, + } + + spaceSet := map[uint]string{} + walkVenues(venueResp.Data, func(obj map[string]any) { + if !textMatches(trimString(obj["sportName"]), sportType) { + return + } + if !textMatches(trimString(obj["campusName"]), campusName) { + return + } + if !textMatches(trimString(obj["venueName"]), venueName) { + return + } + if out.VenueID == nil { + if vid := trimString(obj["venueId"]); vid != "" { + v := parseUint(vid) + out.VenueID = &v + } + } + if out.VenueSiteID == nil { + if sid := trimString(obj["id"]); sid != "" { + v := parseUint(sid) + out.VenueSiteID = &v + } + } + if spaceID := trimString(obj["spaceId"]); spaceID != "" { + id := parseUint(spaceID) + spaceSet[id] = trimString(obj["spaceName"]) + } + }) + + for id, name := range spaceSet { + out.Spaces = append(out.Spaces, TemplateSpace{SpaceID: id, SpaceName: name}) + } + + // 尝试从明日的 dayInfo 取时间段模板;失败不影响主结果。 + if out.VenueID != nil && out.VenueSiteID != nil { + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + params := url.Values{} + params.Set("venueId", strconv.FormatUint(uint64(*out.VenueID), 10)) + params.Set("venueSiteId", strconv.FormatUint(uint64(*out.VenueSiteID), 10)) + params.Set("siteId", strconv.FormatUint(uint64(*out.VenueSiteID), 10)) + params.Set("date", tomorrow) + params.Set("reservationDate", tomorrow) + params.Set("searchDate", tomorrow) + if dayResp, dayErr := s.tyys.ReservationDayInfo(ctx, params); dayErr == nil { + slotSet := map[string]TemplateTimeSlot{} + walkSlots(dayResp.Data, func(slot map[string]any) { + start := trimString(slot["startDate"]) + end := trimString(slot["endDate"]) + key := start + "|" + end + if _, exists := slotSet[key]; exists { + return + } + ts := TemplateTimeSlot{ + StartTime: formatTimeOnly(start), + EndTime: formatTimeOnly(end), + DisplayLabel: formatTimeOnly(start) + "-" + formatTimeOnly(end), + } + if tid := trimString(slot["timeId"]); tid != "" { + v := parseUint(tid) + ts.TimeID = &v + } + slotSet[key] = ts + }) + for _, ts := range slotSet { + out.TimeSlots = append(out.TimeSlots, ts) + } + } + } + + return out, nil +} + +// CreatePlan 创建远期预约计划,只保存预约意图,不立即调 TYYS。 +// 计划状态为 scheduled,reserve_open_at 根据首个 PlanSlot 的校区+球类自动计算。 +func (s *ReservationService) CreatePlan(ctx context.Context, input ReservationPlanInput) (*models.RoomReservation, error) { + if len(input.PlanSlots) == 0 { + return nil, fmt.Errorf("plan requires at least one slot selection") + } + // 取所有场次中最早的预约开放时间,确保调度器能在第一个窗口开放时触发。 + var openAt time.Time + for _, ps := range input.PlanSlots { + t, err := reserveOpenAt(input.ReservationDate, ps.CampusName, input.SportType) + if err != nil { + return nil, err + } + if openAt.IsZero() || t.Before(openAt) { + openAt = t + } + } + + planSlotsJSON := marshalPlanSlots(input.PlanSlots) + record := &models.RoomReservation{ + RoomID: input.RoomID, + Provider: "tyys", + SportType: input.SportType, + ReservationDate: input.ReservationDate, + // CampusName/VenueName/StartTime/EndTime 在 materializeOne 成功后由 trySlots 写入。 + BuddyCode: stringVal(input.BuddyCode), + PlanSlots: planSlotsJSON, + ReservationStatus: "scheduled", + ReserveOpenAt: &openAt, + } + if err := s.reservationRepo.Create(ctx, record); err != nil { + return nil, fmt.Errorf("create plan: %w", err) + } + return record, nil +} + +// Preview 对每个候选 slot 调用 TYYS orderInfo 做预校验,不创建 DB 记录。 +// 返回全量 slot 校验结果,前端可据此展示哪些场地可预约并让用户二次确认。 func (s *ReservationService) Preview(ctx context.Context, input ReservationPreviewInput) (*ReservationPreviewOutput, error) { - return nil, fmt.Errorf("reservation service Preview not implemented") + if len(input.Slots) == 0 { + return nil, fmt.Errorf("preview requires at least one slot selection") + } + + out := &ReservationPreviewOutput{ + RoomID: input.RoomID, + Provider: "tyys", + SportType: input.SportType, + ReservationDate: input.ReservationDate, + BuddyCode: input.BuddyCode, + } + + for _, slot := range input.Slots { + form := url.Values{} + form.Set("venueSiteId", strconv.FormatUint(uint64(slot.VenueSiteID), 10)) + form.Set("reservationDate", input.ReservationDate) + form.Set("weekStartDate", coalesce(slot.WeekStart, input.ReservationDate)) + form.Set("token", slot.Token) + form.Set("reservationOrderJson", mustMarshalJSON([]map[string]any{{ + "spaceId": strconv.FormatUint(uint64(slot.SpaceID), 10), + "timeId": strconv.FormatUint(uint64(slot.TimeID), 10), + "venueSpaceGroupId": nil, + }})) + _, err := s.tyys.ReservationOrderInfo(ctx, form) + item := SlotPreviewItem{Slot: slot, Available: err == nil} + if err != nil { + item.Error = err.Error() + } + out.Slots = append(out.Slots, item) + } + return out, nil } +// Submit 创建预约记录并通过 trySlots 依次尝试候选场地提交 TYYS(实时路径 ≤2天)。 +// 前端从 /reservations/slots 选取候选列表后传入,服务端自动选第一个成功的场地。 func (s *ReservationService) Submit(ctx context.Context, input ReservationPreviewInput) (*models.RoomReservation, error) { - return nil, fmt.Errorf("reservation service Submit not implemented") + if len(input.Slots) == 0 { + return nil, fmt.Errorf("submit requires at least one slot selection") + } + + first := input.Slots[0] + now := time.Now() + venueSiteID, spaceID, timeID := first.VenueSiteID, first.SpaceID, first.TimeID + record := &models.RoomReservation{ + RoomID: input.RoomID, + Provider: "tyys", + SportType: input.SportType, + CampusName: first.CampusName, + VenueName: first.VenueName, + ReservationDate: input.ReservationDate, + StartTime: first.StartTime, + EndTime: first.EndTime, + VenueID: first.VenueID, + VenueSiteID: &venueSiteID, + SpaceID: &spaceID, + SpaceName: stringVal(first.SpaceName), + TimeID: &timeID, + Token: first.Token, + WeekStartDate: first.WeekStart, + BuddyCode: stringVal(input.BuddyCode), + ReservationStatus: "submitting", + SubmitAttemptedAt: &now, + } + if err := s.reservationRepo.Create(ctx, record); err != nil { + return nil, fmt.Errorf("create reservation record: %w", err) + } + + _ = s.trySlots(ctx, record, input.Slots) + return record, nil +} + +// trySlots 是实时路径和计划路径共用的 slot 执行引擎。 +// 依次尝试每个候选 slot,首个成功即返回 nil;每次尝试前将 slot 的全部上下文(含 campus/venue/start/end)写入 record 并同步到 DB。 +func (s *ReservationService) trySlots(ctx context.Context, record *models.RoomReservation, slots []SlotSelection) error { + var lastErr error + for _, slot := range slots { + venueSiteID, spaceID, timeID := slot.VenueSiteID, slot.SpaceID, slot.TimeID + record.VenueSiteID = &venueSiteID + record.SpaceID = &spaceID + record.TimeID = &timeID + record.Token = slot.Token + record.WeekStartDate = slot.WeekStart + record.SpaceName = stringVal(slot.SpaceName) + if slot.CampusName != "" { + record.CampusName = slot.CampusName + } + if slot.VenueName != "" { + record.VenueName = slot.VenueName + } + if slot.StartTime != "" { + record.StartTime = slot.StartTime + } + if slot.EndTime != "" { + record.EndTime = slot.EndTime + } + if slot.VenueID != nil { + record.VenueID = slot.VenueID + } + if err := s.reservationRepo.Update(ctx, record); err != nil { + lastErr = err + continue + } + if err := s.executeReservation(ctx, record); err == nil { + return nil + } else { + lastErr = err + } + } + return lastErr +} + +// resolvePreferredSlots 仅供计划路径(materializeOne)使用。 +// 按 PlanSlots 中的 (campus, venue) 分组调用 ListSlots,过滤 space_id 后返回候选列表。 +// 不按时间过滤;trySlots 会依次尝试所有候选 slot。 +func (s *ReservationService) resolvePreferredSlots(ctx context.Context, plan *models.RoomReservation) ([]SlotSelection, error) { + planSlots := unmarshalPlanSlots(plan.PlanSlots) + if len(planSlots) == 0 { + return nil, fmt.Errorf("plan has no slot selections") + } + + type venueKey struct{ campus, venue string } + byVenue := make(map[venueKey][]PlanSlotSelection) + for _, ps := range planSlots { + k := venueKey{ps.CampusName, ps.VenueName} + byVenue[k] = append(byVenue[k], ps) + } + + var candidates []SlotSelection + for key, group := range byVenue { + liveSlots, err := s.ListSlots(ctx, plan.SportType, key.campus, key.venue, plan.ReservationDate) + if err != nil { + continue // 该场馆暂不可查,跳过 + } + spaceMap := make(map[uint]PlanSlotSelection, len(group)) + for _, ps := range group { + spaceMap[ps.SpaceID] = ps + } + for _, live := range liveSlots { + if !live.Available { + continue + } + ps, ok := spaceMap[live.SpaceID] + if !ok { + continue + } + candidates = append(candidates, SlotSelection{ + CampusName: key.campus, + VenueName: key.venue, + VenueID: ps.VenueID, + VenueSiteID: live.VenueSiteID, + SpaceID: live.SpaceID, + SpaceName: live.SpaceName, + StartTime: live.StartTime, + EndTime: live.EndTime, + TimeID: live.TimeID, + Token: live.Token, + WeekStart: live.WeekStart, + }) + } + } + return candidates, nil +} + +// TriggerReservation 由调度器通过 public_id 触发单条预约提交。 +// 使用原子状态切换防止并发双触发。 +func (s *ReservationService) TriggerReservation(ctx context.Context, publicID string) (*models.RoomReservation, error) { + record, err := s.reservationRepo.GetByPublicID(ctx, publicID) + if err != nil { + return nil, fmt.Errorf("reservation not found: %w", err) + } + + ok, err := s.reservationRepo.AtomicTransitionStatus(ctx, record.PublicID, "pending", "submitting") + if err != nil || !ok { + return nil, fmt.Errorf("reservation already processing or not in pending state: %w", err) + } + // 重新加载最新状态 + record, _ = s.reservationRepo.GetByID(ctx, record.ID) + + _ = s.executeReservation(ctx, record) + record, _ = s.reservationRepo.GetByID(ctx, record.ID) + return record, nil +} + +// MaterializePlans 查找所有已到开放时间的 scheduled 计划,补全 slot 上下文后提交 TYYS。 +// 并发执行,返回汇总结果。 +func (s *ReservationService) MaterializePlans(ctx context.Context, dryRun bool) MaterializeResult { + plans, err := s.reservationRepo.ListDueScheduled(ctx, time.Now()) + if err != nil { + return MaterializeResult{Errors: []string{fmt.Sprintf("list scheduled: %s", err)}} + } + + result := MaterializeResult{Total: len(plans)} + if dryRun || len(plans) == 0 { + return result + } + + var mu sync.Mutex + var wg sync.WaitGroup + for _, plan := range plans { + wg.Add(1) + go func(p *models.RoomReservation) { + defer wg.Done() + if materializeErr := s.materializeOne(ctx, p); materializeErr != nil { + mu.Lock() + result.Failed++ + result.Errors = append(result.Errors, fmt.Sprintf("reservation %s: %s", p.PublicID, materializeErr)) + mu.Unlock() + return + } + mu.Lock() + result.Succeeded++ + mu.Unlock() + }(plan) + } + wg.Wait() + return result +} + +// materializeOne 补全单条计划的 slot 上下文并提交 TYYS。 +// 仅在 reserve_open_at <= now 时被调用,此时 TYYS 预约窗口已开放,ListSlots 必然可用。 +func (s *ReservationService) materializeOne(ctx context.Context, plan *models.RoomReservation) error { + ok, err := s.reservationRepo.AtomicTransitionStatus(ctx, plan.PublicID, "scheduled", "submitting") + if err != nil || !ok { + return fmt.Errorf("already processing") + } + + candidates, resolveErr := s.resolvePreferredSlots(ctx, plan) + if resolveErr != nil { + s.failReservation(ctx, plan, resolveErr.Error()) + return resolveErr + } + if len(candidates) == 0 { + err := fmt.Errorf("no available slot for %s-%s on %s matching preferred spaces", plan.StartTime, plan.EndTime, plan.ReservationDate) + s.failReservation(ctx, plan, err.Error()) + return err + } + + if err := s.trySlots(ctx, plan, candidates); err != nil { + s.failReservation(ctx, plan, err.Error()) + return err + } + return nil +} + +// executeReservation 调用 TYYS ReserveV2,对验证码失败最多重试 5 次。 +// 无论成功还是失败都回写 reservation_status 并记录 attempt log。 +func (s *ReservationService) executeReservation(ctx context.Context, record *models.RoomReservation) error { + if record.VenueSiteID == nil || record.SpaceID == nil || record.TimeID == nil || record.Token == "" { + err := fmt.Errorf("missing slot context (venue_site_id/space_id/time_id/token)") + s.failReservation(ctx, record, err.Error()) + return err + } + + solver := zjulogin.TYYSPythonCaptchaSolver{} + req := zjulogin.TYYSReservationV2Request{ + ReservationDate: record.ReservationDate, + WeekStartDate: coalesce(record.WeekStartDate, record.ReservationDate), + Token: record.Token, + VenueSiteID: strconv.FormatUint(uint64(*record.VenueSiteID), 10), + SpaceID: strconv.FormatUint(uint64(*record.SpaceID), 10), + TimeID: strconv.FormatUint(uint64(*record.TimeID), 10), + BuddyCode: record.BuddyCode, + CaptchaSolver: solver, + } + + const maxRetries = 5 + var lastErr error + var lastResult *zjulogin.TYYSReservationV2Result + + for attempt := 1; attempt <= maxRetries; attempt++ { + result, execErr := s.tyys.ReserveV2(ctx, req) + lastResult = result + lastErr = execErr + if execErr == nil { + break + } + // 只对验证码失败重试。 + if !isCaptchaError(execErr) { + break + } + } + + stage := "submit" + success := lastErr == nil + msg := "" + if lastErr != nil { + msg = lastErr.Error() + } + + rawResp := "" + orderID := "" + tradeNo := "" + if lastResult != nil && lastResult.Submit != nil { + raw, _ := json.Marshal(lastResult.Submit) + rawResp = string(raw) + extractOrderFields(lastResult.Submit.Data, &orderID, &tradeNo) + } + + now := time.Now() + record.SubmitAttemptedAt = &now + record.RawResponse = rawResp + if success { + record.ReservationStatus = "success" + record.ExternalOrderID = orderID + record.ExternalTradeNo = tradeNo + } else { + record.ReservationStatus = "failed" + } + _ = s.reservationRepo.Update(ctx, record) + + logEntry := &models.ReservationAttemptLog{ + RoomID: &record.RoomID, + ReservationID: &record.ID, + Stage: stage, + Success: success, + Message: msg, + } + if lastResult != nil && lastResult.Submit != nil { + raw, _ := json.Marshal(lastResult.Submit) + logEntry.ResponseSnapshot = string(raw) + } + _ = s.reservationRepo.CreateAttemptLog(ctx, logEntry) + + return lastErr +} + +// failReservation 将预约状态标记为 failed 并记录日志。 +func (s *ReservationService) failReservation(ctx context.Context, record *models.RoomReservation, msg string) { + record.ReservationStatus = "failed" + _ = s.reservationRepo.Update(ctx, record) + _ = s.reservationRepo.CreateAttemptLog(ctx, &models.ReservationAttemptLog{ + RoomID: &record.RoomID, + ReservationID: &record.ID, + Stage: "materialize", + Success: false, + Message: msg, + }) +} + +// isCaptchaError 判断错误是否属于验证码失败,只有这类错误才应重试。 +func isCaptchaError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "captcha") || strings.Contains(msg, "repcode=6111") || strings.Contains(msg, "验证失败") +} + +// marshalPlanSlots 将 []PlanSlotSelection 序列化为 JSON 字符串;空切片返回 ""。 +func marshalPlanSlots(slots []PlanSlotSelection) string { + if len(slots) == 0 { + return "" + } + b, _ := json.Marshal(slots) + return string(b) +} + +// unmarshalPlanSlots 将 JSON 字符串反序列化为 []PlanSlotSelection;空或非法字符串返回 nil。 +func unmarshalPlanSlots(s string) []PlanSlotSelection { + if s == "" { + return nil + } + var slots []PlanSlotSelection + _ = json.Unmarshal([]byte(s), &slots) + return slots +} + +// formatTimeOnly 从 "YYYY-MM-DD HH:mm" 或 "HH:mm" 格式中提取 "HH:mm" 部分。 +func formatTimeOnly(s string) string { + if len(s) > 5 { + return s[len(s)-5:] + } + return s +} + +// parseUint 将字符串转换为 uint,失败返回 0。 +func parseUint(s string) uint { + v, _ := strconv.ParseUint(s, 10, 64) + return uint(v) +} + +// coalesce 返回第一个非空字符串。 +func coalesce(a, b string) string { + if a != "" { + return a + } + return b +} + +// stringVal 从指针安全取值。 +func stringVal(p *string) string { + if p == nil { + return "" + } + return *p +} + +// mustMarshalJSON 序列化为 JSON,失败返回 "null"。 +func mustMarshalJSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + return "null" + } + return string(b) +} + +// extractOrderFields 从 TYYS submit 响应 data 中提取订单 ID 和交易号。 +func extractOrderFields(data json.RawMessage, orderID, tradeNo *string) { + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + return + } + if v := trimString(obj["orderId"]); v != "" { + *orderID = v + } + if v := trimString(obj["tradeNo"]); v != "" { + *tradeNo = v + } } diff --git a/internal/service/room_service.go b/internal/service/room_service.go index 5b59fb5..fd7d763 100644 --- a/internal/service/room_service.go +++ b/internal/service/room_service.go @@ -123,6 +123,10 @@ func (s *RoomService) GetByID(ctx context.Context, id uint) (*models.Room, []mod return nil, nil, fmt.Errorf("room service GetByID not implemented") } +func (s *RoomService) GetByPublicID(ctx context.Context, publicID string) (*models.Room, []models.RoomMember, error) { + return nil, nil, fmt.Errorf("room service GetByPublicID not implemented") +} + func (s *RoomService) Create(ctx context.Context, input CreateRoomInput) (*models.Room, error) { return nil, fmt.Errorf("room service Create not implemented") } diff --git a/models/room_reservation.go b/models/room_reservation.go index 2cc0bdb..165312b 100644 --- a/models/room_reservation.go +++ b/models/room_reservation.go @@ -26,6 +26,9 @@ type RoomReservation struct { WeekStartDate string `gorm:"size:10"` BuddyCode string `gorm:"size:32"` BuddyUserIDs string `gorm:"type:text"` + // PlanSlots 是用户创建远期预约计划时指定的首选场次列表(JSON array,格式为 PlanSlotSelection)。 + // 调度器补全时依次尝试匹配;若为空或 "[]",则接受计划所属场馆内任意可用 slot。 + PlanSlots string `gorm:"type:text"` ReservationStatus string `gorm:"size:32;not null;default:'pending';index"` ScheduleStatus string `gorm:"size:32;not null;default:'none';index"` ReserveOpenAt *time.Time From 863854a92784bf7e6893d8c84dc8b55ce57f560a Mon Sep 17 00:00:00 2001 From: GP Date: Thu, 30 Apr 2026 14:45:01 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E5=AE=9A=E6=9C=9F=E6=B8=85=E7=90=86?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E7=9A=84=20failed=20=E9=A2=84=E7=BA=A6=20+?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=20failed=20=E7=8A=B6=E6=80=81=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: - ListDueScheduled 只查 scheduled 状态,导致 failed 的计划永不重试 - 当预约开放时间到达但部分场馆 ListSlots 尚不可用时,计划被标记为 failed 后无法被调度器再次尝试 - failed 中预约时段已过实际时间的记录会被无限重试,浪费调度资源 主要变更: 【Repository】 - ListDueScheduled 扩展为查询 reservation_status IN ('scheduled','failed'), 使 failed 状态的计划每小时也能被重试(兜底场馆窗口延迟开放的情况) - 新增 MarkExpiredFailed:将 failed 且 reservation_date < today 的记录 标记为 expired,阻止调度器继续无效重试 【Service】 - materializeOne:AtomicTransitionStatus 兼容 scheduled 和 failed 两种 初始状态,均可原子切换到 submitting - MaterializeResult 新增 Expired 字段 - MaterializePlans:每次执行后调用 MarkExpiredFailed 清理当日前的过期记录 【API / OpenAPI】 - ReservationMaterializeResult 新增 expired 字段,返回本次清理数量 Co-Authored-By: Claude Sonnet 4.6 --- api/openapi/openapi.yaml | 5 ++++- internal/api/gen/api.gen.go | 13 +++++++----- internal/api/handler.go | 1 + internal/repository/reservation_repository.go | 16 +++++++++++--- internal/service/reservation_service.go | 21 ++++++++++++++++--- 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 3e164c3..2bbe0e3 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -1580,7 +1580,7 @@ components: description: 若为 true,只返回待处理计划数量,不实际执行 ReservationMaterializeResult: type: object - required: [total, succeeded, failed] + required: [total, succeeded, failed, expired] description: 预约计划批量补全结果,返回本次调度处理的计划数量及失败详情 properties: total: @@ -1589,6 +1589,9 @@ components: type: integer failed: type: integer + expired: + type: integer + description: 本次清理中标记为 expired 的过期失败预约数量 errors: type: array items: diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index 3501695..c603b90 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -99,17 +99,20 @@ type ReservationMaterializeRequest struct { // ReservationMaterializeResult 预约计划批量补全结果,返回本次调度处理的计划数量及失败详情 type ReservationMaterializeResult struct { - Errors *[]string `json:"errors,omitempty"` - Failed int `json:"failed"` - Succeeded int `json:"succeeded"` - Total int `json:"total"` + Errors *[]string `json:"errors,omitempty"` + + // Expired 本次清理中标记为 expired 的过期失败预约数量 + Expired int `json:"expired"` + Failed int `json:"failed"` + Succeeded int `json:"succeeded"` + Total int `json:"total"` } // ReservationPlanRequest 创建预约计划请求(仅保存预约意图,不立即调 TYYS) type ReservationPlanRequest struct { BuddyCode *string `json:"buddy_code,omitempty"` - // PlanSlots 首选场次列表,campus/venue/start/end 均在每个 slot 中携带;调度器依次尝试直到一个成功 + // PlanSlots 选中场次列表,campus/venue/start/end 均在每个 slot 中携带;调度器依次尝试直到一个成功 PlanSlots []ReservationPlanSlotSelection `json:"plan_slots"` ReservationDate openapi_types.Date `json:"reservation_date"` SportType string `json:"sport_type"` diff --git a/internal/api/handler.go b/internal/api/handler.go index bd74603..15e9954 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -496,6 +496,7 @@ func (h *Handler) TriggerReservationMaterialize(c *gin.Context) { Total: result.Total, Succeeded: result.Succeeded, Failed: result.Failed, + Expired: result.Expired, } if len(result.Errors) > 0 { resp.Errors = &result.Errors diff --git a/internal/repository/reservation_repository.go b/internal/repository/reservation_repository.go index 2910561..306d21e 100644 --- a/internal/repository/reservation_repository.go +++ b/internal/repository/reservation_repository.go @@ -54,16 +54,26 @@ func (r *ReservationRepository) Update(ctx context.Context, reservation *models. return r.db.WithContext(ctx).Save(reservation).Error } -// ListDueScheduled 返回所有 status=scheduled 且 reserve_open_at <= now 的计划, -// 供 materialize 调度器判断哪些计划已到开放时间。 +// ListDueScheduled 返回所有 schedule_status=scheduled 或 failed(可重试)且 reserve_open_at <= now 的计划, +// 供 materialize 调度器判断哪些计划已到开放时间。failed 状态的计划每小时也会被重试,以处理 +// 首次执行时部分场馆预约窗口尚未开放的情况。 func (r *ReservationRepository) ListDueScheduled(ctx context.Context, now time.Time) ([]*models.RoomReservation, error) { var reservations []*models.RoomReservation err := r.db.WithContext(ctx). - Where("reservation_status = ? AND reserve_open_at <= ?", "scheduled", now). + Where("reservation_status IN ? AND reserve_open_at <= ?", []string{"scheduled", "failed"}, now). Find(&reservations).Error return reservations, err } +// MarkExpiredFailed 将 status=failed 且预约日期早于 beforeDate 的计划标记为 expired, +// 防止调度器继续重试已无法执行的过期计划。 +func (r *ReservationRepository) MarkExpiredFailed(ctx context.Context, beforeDate string) (int64, error) { + result := r.db.WithContext(ctx).Model(&models.RoomReservation{}). + Where("reservation_status = ? AND reservation_date < ?", "failed", beforeDate). + Update("reservation_status", "expired") + return result.RowsAffected, result.Error +} + // AtomicTransitionStatus 原子地将记录从 fromStatus 切换到 toStatus,防止并发双触发。 // 使用 public_id 定位记录,避免整数 ID 在内部流转。 // 返回 false 表示记录已被其他执行者抢占,调用方应直接放弃本次执行。 diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index d622097..504e677 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -131,6 +131,7 @@ type MaterializeResult struct { Total int Succeeded int Failed int + Expired int Errors []string } @@ -718,14 +719,28 @@ func (s *ReservationService) MaterializePlans(ctx context.Context, dryRun bool) }(plan) } wg.Wait() + + // 清理 failed 中预约时段已过期的记录,防止调度器继续无效重试。 + loc, _ := time.LoadLocation("Asia/Shanghai") + today := time.Now().In(loc).Format("2006-01-02") + if expired, cleanErr := s.reservationRepo.MarkExpiredFailed(ctx, today); cleanErr != nil { + result.Errors = append(result.Errors, fmt.Sprintf("cleanup expired: %s", cleanErr)) + } else { + result.Expired = int(expired) + } + return result } // materializeOne 补全单条计划的 slot 上下文并提交 TYYS。 -// 仅在 reserve_open_at <= now 时被调用,此时 TYYS 预约窗口已开放,ListSlots 必然可用。 +// 仅在 reserve_open_at <= now 时被调用。failed 状态的计划每小时也会重试(部分场馆窗口尚未开放时的兜底)。 func (s *ReservationService) materializeOne(ctx context.Context, plan *models.RoomReservation) error { - ok, err := s.reservationRepo.AtomicTransitionStatus(ctx, plan.PublicID, "scheduled", "submitting") - if err != nil || !ok { + // 尝试从 scheduled 或 failed 原子切换到 submitting,防止并发双触发。 + ok, _ := s.reservationRepo.AtomicTransitionStatus(ctx, plan.PublicID, "scheduled", "submitting") + if !ok { + ok, _ = s.reservationRepo.AtomicTransitionStatus(ctx, plan.PublicID, "failed", "submitting") + } + if !ok { return fmt.Errorf("already processing") } From 3c3e791f5ab3994ac13e0b4a8c945e842b9d5171 Mon Sep 17 00:00:00 2001 From: GP Date: Thu, 30 Apr 2026 15:04:26 +0800 Subject: [PATCH 5/8] add test --- .../repository/reservation_repository_test.go | 375 ++++++++++++++++++ internal/service/reservation_service_test.go | 190 +++++++++ 2 files changed, 565 insertions(+) create mode 100644 internal/repository/reservation_repository_test.go create mode 100644 internal/service/reservation_service_test.go diff --git a/internal/repository/reservation_repository_test.go b/internal/repository/reservation_repository_test.go new file mode 100644 index 0000000..777a09e --- /dev/null +++ b/internal/repository/reservation_repository_test.go @@ -0,0 +1,375 @@ +package repository_test + +import ( + "context" + "fmt" + "os" + "strconv" + "testing" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "github.com/QSCTech/SRTP-Backend/internal/repository" + "github.com/QSCTech/SRTP-Backend/models" +) + +// connectTestDB 从环境变量构建 DSN 并返回 GORM 连接。 +// 若 DB_HOST 未设置,测试将被跳过。 +func connectTestDB(t *testing.T) *gorm.DB { + t.Helper() + host := os.Getenv("DB_HOST") + if host == "" { + t.Skip("set DB_HOST to run repository integration tests (see .env.example)") + } + port, _ := strconv.Atoi(os.Getenv("DB_PORT")) + if port == 0 { + port = 5432 + } + user := os.Getenv("DB_USER") + if user == "" { + user = "postgres" + } + password := os.Getenv("DB_PASSWORD") + if password == "" { + password = "postgres" + } + dbName := os.Getenv("DB_NAME") + if dbName == "" { + dbName = "srtp" + } + sslmode := os.Getenv("DB_SSLMODE") + if sslmode == "" { + sslmode = "disable" + } + + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai", + host, port, user, password, dbName, sslmode) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + }) + if err != nil { + t.Fatalf("connect test db: %v", err) + } + + if err := db.AutoMigrate(&models.User{}, &models.Room{}, &models.RoomReservation{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + return db +} + +// seedRoom 在测试库中创建一个最小化 User 和 Room,返回 Room.ID。 +// t.Cleanup 中自动删除。 +func seedRoom(t *testing.T, db *gorm.DB) uint { + t.Helper() + + user := &models.User{ + AuthUID: fmt.Sprintf("test-uid-%d", time.Now().UnixNano()), + } + if err := db.Create(user).Error; err != nil { + t.Fatalf("create test user: %v", err) + } + + room := &models.Room{ + OwnerID: user.ID, + Name: "测试球场", + SportType: "篮球", + CampusName: "紫金港", + VenueName: "篮球馆", + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(25 * time.Hour), + } + if err := db.Create(room).Error; err != nil { + t.Fatalf("create test room: %v", err) + } + + t.Cleanup(func() { + db.Where("room_id = ?", room.ID).Delete(&models.RoomReservation{}) + db.Delete(room) + db.Delete(user) + }) + + return room.ID +} + +// seedReservation 创建一条预约记录并在测试结束时删除。 +func seedReservation(t *testing.T, db *gorm.DB, r *models.RoomReservation) *models.RoomReservation { + t.Helper() + if err := db.Create(r).Error; err != nil { + t.Fatalf("create reservation: %v", err) + } + t.Cleanup(func() { db.Delete(r) }) + return r +} + +// --- ListDueScheduled --- + +func TestListDueScheduled_IncludesScheduledAndFailed(t *testing.T) { + db := connectTestDB(t) + repo := repository.NewReservationRepository(db) + roomID := seedRoom(t, db) + + past := time.Now().Add(-1 * time.Hour) + future := time.Now().Add(1 * time.Hour) + + cases := []struct { + status string + reserveOpenAt *time.Time + wantIncluded bool + desc string + }{ + {"scheduled", &past, true, "scheduled + past open_at → 应被返回"}, + {"failed", &past, true, "failed + past open_at → 应被返回(新行为)"}, + {"expired", &past, false, "expired + past open_at → 不应被返回"}, + {"success", &past, false, "success + past open_at → 不应被返回"}, + {"scheduled", &future, false, "scheduled + 未来 open_at → 不应被返回"}, + {"failed", &future, false, "failed + 未来 open_at → 不应被返回"}, + } + + ids := make(map[uint]bool) + for _, c := range cases { + r := seedReservation(t, db, &models.RoomReservation{ + RoomID: roomID, + SportType: "篮球", + CampusName: "紫金港", + VenueName: "篮球馆", + ReservationDate: "2099-01-01", + StartTime: "09:00", + EndTime: "10:00", + ReservationStatus: c.status, + ScheduleStatus: "none", + ReserveOpenAt: c.reserveOpenAt, + }) + if c.wantIncluded { + ids[r.ID] = false // false = not yet seen + } + } + + results, err := repo.ListDueScheduled(context.Background(), time.Now()) + if err != nil { + t.Fatalf("ListDueScheduled: %v", err) + } + + for _, r := range results { + if _, ok := ids[r.ID]; ok { + ids[r.ID] = true // seen + } + } + + for id, seen := range ids { + if !seen { + t.Errorf("reservation ID=%d expected in result but not found", id) + } + } + + // 验证不期望出现的 ID 确实没有出现 + for _, r := range results { + for _, c := range cases { + if !c.wantIncluded { + // 检查返回结果中不含非预期状态(只检查我们创建的记录) + // 通过 room_id 过滤避免误报其他测试的数据 + if r.RoomID == roomID && r.ReservationStatus == c.status { + if c.reserveOpenAt != nil && c.reserveOpenAt.Before(time.Now()) { + t.Errorf("status=%q 不应被 ListDueScheduled 返回", c.status) + } + } + } + } + } +} + +// --- MarkExpiredFailed --- + +func TestMarkExpiredFailed_OnlyAffectsFailedAndPastDate(t *testing.T) { + db := connectTestDB(t) + repo := repository.NewReservationRepository(db) + roomID := seedRoom(t, db) + + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + today := time.Now().Format("2006-01-02") + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + + cases := []struct { + status string + date string + wantExpired bool + desc string + }{ + {"failed", yesterday, true, "failed + 昨天 → 应标记为 expired"}, + {"failed", today, false, "failed + 今天 → 不应标记(需要 < today)"}, + {"failed", tomorrow, false, "failed + 明天 → 不应标记"}, + {"scheduled", yesterday, false, "scheduled + 昨天 → 不应标记"}, + {"success", yesterday, false, "success + 昨天 → 不应标记"}, + } + + reservations := make([]*models.RoomReservation, len(cases)) + for i, c := range cases { + r := seedReservation(t, db, &models.RoomReservation{ + RoomID: roomID, + SportType: "篮球", + CampusName: "紫金港", + VenueName: "篮球馆", + ReservationDate: c.date, + StartTime: "09:00", + EndTime: "10:00", + ReservationStatus: c.status, + ScheduleStatus: "none", + }) + reservations[i] = r + } + + count, err := repo.MarkExpiredFailed(context.Background(), today) + if err != nil { + t.Fatalf("MarkExpiredFailed: %v", err) + } + + // 统计本次应被标记的数量 + wantCount := 0 + for _, c := range cases { + if c.wantExpired { + wantCount++ + } + } + if int(count) < wantCount { + t.Errorf("MarkExpiredFailed affected %d rows, want at least %d", count, wantCount) + } + + // 验证每条记录的最终状态 + for i, c := range cases { + var r models.RoomReservation + if err := db.First(&r, reservations[i].ID).Error; err != nil { + t.Fatalf("[%d] reload reservation: %v", i, err) + } + if c.wantExpired && r.ReservationStatus != "expired" { + t.Errorf("[%d] %s: status = %q, want \"expired\"", i, c.desc, r.ReservationStatus) + } + if !c.wantExpired && r.ReservationStatus == "expired" { + t.Errorf("[%d] %s: status unexpectedly set to \"expired\"", i, c.desc) + } + } +} + +// --- AtomicTransitionStatus --- + +func TestAtomicTransitionStatus_SuccessFromScheduled(t *testing.T) { + db := connectTestDB(t) + repo := repository.NewReservationRepository(db) + roomID := seedRoom(t, db) + + r := seedReservation(t, db, &models.RoomReservation{ + RoomID: roomID, + SportType: "篮球", + CampusName: "紫金港", + VenueName: "篮球馆", + ReservationDate: "2099-06-01", + StartTime: "09:00", + EndTime: "10:00", + ReservationStatus: "scheduled", + ScheduleStatus: "none", + }) + + ok, err := repo.AtomicTransitionStatus(context.Background(), r.PublicID, "scheduled", "submitting") + if err != nil { + t.Fatalf("AtomicTransitionStatus: %v", err) + } + if !ok { + t.Error("expected ok=true, got false") + } + + var updated models.RoomReservation + db.First(&updated, r.ID) + if updated.ReservationStatus != "submitting" { + t.Errorf("status = %q, want \"submitting\"", updated.ReservationStatus) + } +} + +func TestAtomicTransitionStatus_SuccessFromFailed(t *testing.T) { + db := connectTestDB(t) + repo := repository.NewReservationRepository(db) + roomID := seedRoom(t, db) + + r := seedReservation(t, db, &models.RoomReservation{ + RoomID: roomID, + SportType: "篮球", + CampusName: "紫金港", + VenueName: "篮球馆", + ReservationDate: "2099-06-01", + StartTime: "09:00", + EndTime: "10:00", + ReservationStatus: "failed", + ScheduleStatus: "none", + }) + + // materializeOne 对 failed 状态也需要能原子切换 + ok, err := repo.AtomicTransitionStatus(context.Background(), r.PublicID, "failed", "submitting") + if err != nil { + t.Fatalf("AtomicTransitionStatus: %v", err) + } + if !ok { + t.Error("expected ok=true for failed→submitting, got false") + } + + var updated models.RoomReservation + db.First(&updated, r.ID) + if updated.ReservationStatus != "submitting" { + t.Errorf("status = %q, want \"submitting\"", updated.ReservationStatus) + } +} + +func TestAtomicTransitionStatus_WrongFromStatus(t *testing.T) { + db := connectTestDB(t) + repo := repository.NewReservationRepository(db) + roomID := seedRoom(t, db) + + r := seedReservation(t, db, &models.RoomReservation{ + RoomID: roomID, + SportType: "篮球", + CampusName: "紫金港", + VenueName: "篮球馆", + ReservationDate: "2099-06-01", + StartTime: "09:00", + EndTime: "10:00", + ReservationStatus: "submitting", + ScheduleStatus: "none", + }) + + // 尝试从 scheduled 切换,但实际是 submitting → 应该返回 false + ok, _ := repo.AtomicTransitionStatus(context.Background(), r.PublicID, "scheduled", "submitting") + if ok { + t.Error("expected ok=false when fromStatus does not match, got true") + } +} + +func TestAtomicTransitionStatus_ConcurrentPreventsDuplicate(t *testing.T) { + db := connectTestDB(t) + repo := repository.NewReservationRepository(db) + roomID := seedRoom(t, db) + + r := seedReservation(t, db, &models.RoomReservation{ + RoomID: roomID, + SportType: "篮球", + CampusName: "紫金港", + VenueName: "篮球馆", + ReservationDate: "2099-06-01", + StartTime: "09:00", + EndTime: "10:00", + ReservationStatus: "scheduled", + ScheduleStatus: "none", + }) + + // 第一次成功 + ok1, err1 := repo.AtomicTransitionStatus(context.Background(), r.PublicID, "scheduled", "submitting") + if err1 != nil || !ok1 { + t.Fatalf("first transition failed: ok=%v err=%v", ok1, err1) + } + + // 第二次应被阻止(记录已变为 submitting) + ok2, _ := repo.AtomicTransitionStatus(context.Background(), r.PublicID, "scheduled", "submitting") + if ok2 { + t.Error("second transition should be blocked, got ok=true") + } +} diff --git a/internal/service/reservation_service_test.go b/internal/service/reservation_service_test.go new file mode 100644 index 0000000..7132326 --- /dev/null +++ b/internal/service/reservation_service_test.go @@ -0,0 +1,190 @@ +package service + +import ( + "testing" + "time" +) + +// --- marshalPlanSlots / unmarshalPlanSlots --- + +func TestMarshalUnmarshalPlanSlots_RoundTrip(t *testing.T) { + venueID := uint(42) + spaceIDStr := "篮球场A" + original := []PlanSlotSelection{ + { + CampusName: "紫金港", + VenueName: "篮球馆", + VenueID: &venueID, + SpaceID: 101, + SpaceName: &spaceIDStr, + StartTime: "09:00", + EndTime: "10:00", + }, + { + CampusName: "玉泉", + VenueName: "羽毛球馆", + SpaceID: 202, + StartTime: "14:00", + EndTime: "15:00", + }, + } + + encoded := marshalPlanSlots(original) + if encoded == "" { + t.Fatal("marshalPlanSlots returned empty string for non-empty input") + } + + decoded := unmarshalPlanSlots(encoded) + if len(decoded) != len(original) { + t.Fatalf("round-trip length mismatch: got %d, want %d", len(decoded), len(original)) + } + + for i, got := range decoded { + want := original[i] + if got.CampusName != want.CampusName { + t.Errorf("[%d] CampusName: got %q, want %q", i, got.CampusName, want.CampusName) + } + if got.VenueName != want.VenueName { + t.Errorf("[%d] VenueName: got %q, want %q", i, got.VenueName, want.VenueName) + } + if got.SpaceID != want.SpaceID { + t.Errorf("[%d] SpaceID: got %d, want %d", i, got.SpaceID, want.SpaceID) + } + if got.StartTime != want.StartTime { + t.Errorf("[%d] StartTime: got %q, want %q", i, got.StartTime, want.StartTime) + } + if got.EndTime != want.EndTime { + t.Errorf("[%d] EndTime: got %q, want %q", i, got.EndTime, want.EndTime) + } + if (got.VenueID == nil) != (want.VenueID == nil) { + t.Errorf("[%d] VenueID nil mismatch", i) + } else if want.VenueID != nil && *got.VenueID != *want.VenueID { + t.Errorf("[%d] VenueID: got %d, want %d", i, *got.VenueID, *want.VenueID) + } + } +} + +func TestMarshalPlanSlots_EmptySlice(t *testing.T) { + if got := marshalPlanSlots(nil); got != "" { + t.Errorf("nil input: got %q, want empty string", got) + } + if got := marshalPlanSlots([]PlanSlotSelection{}); got != "" { + t.Errorf("empty slice: got %q, want empty string", got) + } +} + +func TestUnmarshalPlanSlots_InvalidJSON(t *testing.T) { + cases := []string{"", "null", "{}", "not-json"} + for _, s := range cases { + got := unmarshalPlanSlots(s) + if len(got) != 0 { + t.Errorf("input %q: expected nil/empty, got %v", s, got) + } + } +} + +// --- venueOpenHour --- + +func TestVenueOpenHour(t *testing.T) { + cases := []struct { + campus string + sport string + wantHour int + }{ + {"紫金港校区", "网球", 8}, + {"玉泉校区", "羽毛球", 12}, + {"华家池校区", "羽毛球", 18}, + {"华家池校区", "网球", 18}, + // 默认:不匹配任何特殊规则 + {"西溪校区", "篮球", 9}, + {"玉泉校区", "网球", 9}, + } + + for _, c := range cases { + got := venueOpenHour(c.campus, c.sport) + if got != c.wantHour { + t.Errorf("venueOpenHour(%q, %q) = %d, want %d", c.campus, c.sport, got, c.wantHour) + } + } +} + +// --- reserveOpenAt --- + +func TestReserveOpenAt_TwoDaysBefore(t *testing.T) { + // 预约日期 2026-05-10(周日),紫金港网球(8:00 开放) + // 期望开放时间:2026-05-08 08:00 Asia/Shanghai + got, err := reserveOpenAt("2026-05-10", "紫金港校区", "网球") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + loc, _ := time.LoadLocation("Asia/Shanghai") + want := time.Date(2026, 5, 8, 8, 0, 0, 0, loc) + if !got.Equal(want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestReserveOpenAt_DefaultHour(t *testing.T) { + // 西溪校区篮球,默认 9:00 + got, err := reserveOpenAt("2026-06-01", "西溪校区", "篮球") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + loc, _ := time.LoadLocation("Asia/Shanghai") + want := time.Date(2026, 5, 30, 9, 0, 0, 0, loc) + if !got.Equal(want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestReserveOpenAt_InvalidDate(t *testing.T) { + _, err := reserveOpenAt("not-a-date", "紫金港", "网球") + if err == nil { + t.Error("expected error for invalid date, got nil") + } +} + +// --- coalesce --- + +func TestCoalesce(t *testing.T) { + if got := coalesce("a", "b"); got != "a" { + t.Errorf("coalesce(\"a\",\"b\") = %q, want \"a\"", got) + } + if got := coalesce("", "b"); got != "b" { + t.Errorf("coalesce(\"\",\"b\") = %q, want \"b\"", got) + } + if got := coalesce("", ""); got != "" { + t.Errorf("coalesce(\"\",\"\") = %q, want \"\"", got) + } +} + +// --- stringVal --- + +func TestStringVal(t *testing.T) { + s := "hello" + if got := stringVal(&s); got != "hello" { + t.Errorf("stringVal(&\"hello\") = %q, want \"hello\"", got) + } + if got := stringVal(nil); got != "" { + t.Errorf("stringVal(nil) = %q, want \"\"", got) + } +} + +// --- formatTimeOnly --- + +func TestFormatTimeOnly(t *testing.T) { + cases := []struct{ in, want string }{ + {"09:00", "09:00"}, + {"2026-05-01 09:00", "09:00"}, + {"18:30", "18:30"}, + {"2026-05-01 18:30", "18:30"}, + } + for _, c := range cases { + got := formatTimeOnly(c.in) + if got != c.want { + t.Errorf("formatTimeOnly(%q) = %q, want %q", c.in, got, c.want) + } + } +} From ed08000e4d6eb9b10bf47e5e35b68d023827aa5e Mon Sep 17 00:00:00 2001 From: GP Date: Thu, 30 Apr 2026 16:07:05 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=86=B2=E7=AA=81=20+=20=E8=A1=A5=E5=85=A8=20slots=20=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E4=B8=8A=E4=B8=8B=E6=96=87=20+=20template=20=E9=99=8D?= =?UTF-8?q?=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: - Gin 不允许同一路径前缀下出现两个不同名通配符(:roomId vs :roomPublicId),导致服务启动 panic - /reservations/slots 返回的字段缺少提交预约所需的执行上下文 (venue_site_id/space_id/time_id/token 等),前端无法直接构建 ReservationSlotSelection - 预约窗口未开放时 slots 端点直接报错,无法服务计划预约场景 主要变更: 【路由修复】 - RoomPublicIdPath component parameter 的 name 从 roomPublicId 改为 roomId,三条预约路由路径从 {roomPublicId} 改为 {roomId},消除 Gin 路由树冲突;handler 参数名同步更新,UUID 查询逻辑不变 【ReservationSlot schema 扩展】 - 新增字段:campus_name / venue_name / venue_id / venue_site_id / space_id / time_id / token / week_start_date - 所有字段均为可选;预约窗口未开放时 token/week_start_date 为空 【ListSlots 逻辑重写】 - Step 1:从 VenueInfo 解析 venueId/venueSiteId 及 resolvedCampus/ resolvedVenue - Step 2:尝试调用 TYYS ReservationDayInfo(可能失败) - Step 3:dayInfo 失败 → 调用 listSlotsFromTemplate 降级,返回 template 时间段骨架,available=false,供计划预约选场使用 - Step 5:dayInfo 成功 → 每个 slot 携带完整上下文(venue_id/ venue_site_id/space_id/time_id/token/week_start_date) 【listSlotsFromTemplate】 - 调用 ListTemplates 获取分场(Spaces)和时间段模板(TimeSlots) - 以 tmpl.Spaces 为分场列表(不依赖 VenueInfo 层是否携带 spaceId) - 无分场数据时用空占位,保证时间段仍能返回 Co-Authored-By: Claude Sonnet 4.6 --- api/openapi/openapi.yaml | 40 ++++++-- internal/api/gen/api.gen.go | 72 ++++++++------ internal/api/handler.go | 48 ++++++++-- internal/service/reservation_service.go | 122 +++++++++++++++++------- 4 files changed, 206 insertions(+), 76 deletions(-) diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 2bbe0e3..75bd36f 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -560,7 +560,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /rooms/{roomPublicId}/reservation/preview: + /rooms/{roomId}/reservation/preview: post: operationId: previewRoomReservation summary: Preview reservation for a room @@ -585,7 +585,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /rooms/{roomPublicId}/reservation/submit: + /rooms/{roomId}/reservation/submit: post: operationId: submitRoomReservation summary: Submit reservation for a room @@ -646,7 +646,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /rooms/{roomPublicId}/reservation/plan: + /rooms/{roomId}/reservation/plan: post: operationId: createRoomReservationPlan summary: Create a reservation plan for a future date (>2 days) @@ -746,7 +746,7 @@ components: minimum: 1 RoomPublicIdPath: in: path - name: roomPublicId + name: roomId required: true schema: type: string @@ -1249,11 +1249,11 @@ components: ReservationSlot: type: object required: [slot_key, start_time, end_time, available] - description: 预约时间段 + description: 预约时间段,包含提交所需的完整执行上下文 properties: slot_key: type: string - description: 时间段唯一标识,用于提交预约 + description: 时间段唯一标识 example: "123456" start_time: type: string @@ -1265,10 +1265,34 @@ components: example: "2026-04-21 09:30" available: type: boolean - description: 是否可预约 + description: 是否可预约;预约窗口未开放时为 false + campus_name: + type: string + venue_name: + type: string + venue_id: + type: integer + format: int64 + venue_site_id: + type: integer + format: int64 + space_id: + type: integer + format: int64 space_name: type: string - description: 场地名称(如游泳馆免费场) + description: 场地名称 + time_id: + type: integer + format: int64 + description: 时间段 ID;预约窗口未开放时可能为空 + token: + type: string + description: 提交预约所需 token;预约窗口未开放时为空 + week_start_date: + type: string + format: date + description: 周起始日期;预约窗口未开放时为空 ReservationSlotListResponse: type: object required: [items] diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index c603b90..5af7e26 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -188,22 +188,36 @@ type ReservationRecordResponse struct { VenueSiteId *int64 `json:"venue_site_id,omitempty"` } -// ReservationSlot 预约时间段 +// ReservationSlot 预约时间段,包含提交所需的完整执行上下文 type ReservationSlot struct { - // Available 是否可预约 - Available bool `json:"available"` + // Available 是否可预约;预约窗口未开放时为 false + Available bool `json:"available"` + CampusName *string `json:"campus_name,omitempty"` // EndTime 结束时间,格式 HH:mm 或 YYYY-MM-DD HH:mm EndTime string `json:"end_time"` - // SlotKey 时间段唯一标识,用于提交预约 + // SlotKey 时间段唯一标识 SlotKey string `json:"slot_key"` + SpaceId *int64 `json:"space_id,omitempty"` - // SpaceName 场地名称(如游泳馆免费场) + // SpaceName 场地名称 SpaceName *string `json:"space_name,omitempty"` // StartTime 开始时间,格式 HH:mm 或 YYYY-MM-DD HH:mm StartTime string `json:"start_time"` + + // TimeId 时间段 ID;预约窗口未开放时可能为空 + TimeId *int64 `json:"time_id,omitempty"` + + // Token 提交预约所需 token;预约窗口未开放时为空 + Token *string `json:"token,omitempty"` + VenueId *int64 `json:"venue_id,omitempty"` + VenueName *string `json:"venue_name,omitempty"` + VenueSiteId *int64 `json:"venue_site_id,omitempty"` + + // WeekStartDate 周起始日期;预约窗口未开放时为空 + WeekStartDate *openapi_types.Date `json:"week_start_date,omitempty"` } // ReservationSlotListResponse defines model for ReservationSlotListResponse. @@ -620,14 +634,14 @@ type ServerInterface interface { // (POST /rooms/{roomId}/reject) RejectJoinRequest(c *gin.Context, roomId RoomIdPath) // Create a reservation plan for a future date (>2 days) - // (POST /rooms/{roomPublicId}/reservation/plan) - CreateRoomReservationPlan(c *gin.Context, roomPublicId RoomPublicIdPath) + // (POST /rooms/{roomId}/reservation/plan) + CreateRoomReservationPlan(c *gin.Context, roomId RoomPublicIdPath) // Preview reservation for a room - // (POST /rooms/{roomPublicId}/reservation/preview) - PreviewRoomReservation(c *gin.Context, roomPublicId RoomPublicIdPath) + // (POST /rooms/{roomId}/reservation/preview) + PreviewRoomReservation(c *gin.Context, roomId RoomPublicIdPath) // Submit reservation for a room - // (POST /rooms/{roomPublicId}/reservation/submit) - SubmitRoomReservation(c *gin.Context, roomPublicId RoomPublicIdPath) + // (POST /rooms/{roomId}/reservation/submit) + SubmitRoomReservation(c *gin.Context, roomId RoomPublicIdPath) // Create a user record // (POST /users) CreateUser(c *gin.Context) @@ -1351,12 +1365,12 @@ func (siw *ServerInterfaceWrapper) CreateRoomReservationPlan(c *gin.Context) { var err error - // ------------- Path parameter "roomPublicId" ------------- - var roomPublicId RoomPublicIdPath + // ------------- Path parameter "roomId" ------------- + var roomId RoomPublicIdPath - err = runtime.BindStyledParameterWithOptions("simple", "roomPublicId", c.Param("roomPublicId"), &roomPublicId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) + err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomPublicId: %w", err), http.StatusBadRequest) + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomId: %w", err), http.StatusBadRequest) return } @@ -1367,7 +1381,7 @@ func (siw *ServerInterfaceWrapper) CreateRoomReservationPlan(c *gin.Context) { } } - siw.Handler.CreateRoomReservationPlan(c, roomPublicId) + siw.Handler.CreateRoomReservationPlan(c, roomId) } // PreviewRoomReservation operation middleware @@ -1375,12 +1389,12 @@ func (siw *ServerInterfaceWrapper) PreviewRoomReservation(c *gin.Context) { var err error - // ------------- Path parameter "roomPublicId" ------------- - var roomPublicId RoomPublicIdPath + // ------------- Path parameter "roomId" ------------- + var roomId RoomPublicIdPath - err = runtime.BindStyledParameterWithOptions("simple", "roomPublicId", c.Param("roomPublicId"), &roomPublicId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) + err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomPublicId: %w", err), http.StatusBadRequest) + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomId: %w", err), http.StatusBadRequest) return } @@ -1391,7 +1405,7 @@ func (siw *ServerInterfaceWrapper) PreviewRoomReservation(c *gin.Context) { } } - siw.Handler.PreviewRoomReservation(c, roomPublicId) + siw.Handler.PreviewRoomReservation(c, roomId) } // SubmitRoomReservation operation middleware @@ -1399,12 +1413,12 @@ func (siw *ServerInterfaceWrapper) SubmitRoomReservation(c *gin.Context) { var err error - // ------------- Path parameter "roomPublicId" ------------- - var roomPublicId RoomPublicIdPath + // ------------- Path parameter "roomId" ------------- + var roomId RoomPublicIdPath - err = runtime.BindStyledParameterWithOptions("simple", "roomPublicId", c.Param("roomPublicId"), &roomPublicId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) + err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "string", Format: "uuid"}) if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomPublicId: %w", err), http.StatusBadRequest) + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter roomId: %w", err), http.StatusBadRequest) return } @@ -1415,7 +1429,7 @@ func (siw *ServerInterfaceWrapper) SubmitRoomReservation(c *gin.Context) { } } - siw.Handler.SubmitRoomReservation(c, roomPublicId) + siw.Handler.SubmitRoomReservation(c, roomId) } // CreateUser operation middleware @@ -1508,9 +1522,9 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/rooms/:roomId/join", wrapper.JoinRoomDirectly) router.POST(options.BaseURL+"/rooms/:roomId/members/:userId/remove", wrapper.RemoveRoomMember) router.POST(options.BaseURL+"/rooms/:roomId/reject", wrapper.RejectJoinRequest) - router.POST(options.BaseURL+"/rooms/:roomPublicId/reservation/plan", wrapper.CreateRoomReservationPlan) - router.POST(options.BaseURL+"/rooms/:roomPublicId/reservation/preview", wrapper.PreviewRoomReservation) - router.POST(options.BaseURL+"/rooms/:roomPublicId/reservation/submit", wrapper.SubmitRoomReservation) + router.POST(options.BaseURL+"/rooms/:roomId/reservation/plan", wrapper.CreateRoomReservationPlan) + router.POST(options.BaseURL+"/rooms/:roomId/reservation/preview", wrapper.PreviewRoomReservation) + router.POST(options.BaseURL+"/rooms/:roomId/reservation/submit", wrapper.SubmitRoomReservation) router.POST(options.BaseURL+"/users", wrapper.CreateUser) router.GET(options.BaseURL+"/users/:id", wrapper.GetUserById) } diff --git a/internal/api/handler.go b/internal/api/handler.go index 15e9954..7d91f83 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -375,18 +375,52 @@ func (h *Handler) ListReservationSlots(c *gin.Context, params gen.ListReservatio } 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}) + s := gen.ReservationSlot{ + SlotKey: item.SlotKey, + StartTime: item.StartTime, + EndTime: item.EndTime, + Available: item.Available, + CampusName: &item.CampusName, + VenueName: &item.VenueName, + SpaceName: item.SpaceName, + } + if item.VenueID != nil { + v := int64(*item.VenueID) + s.VenueId = &v + } + if item.VenueSiteID != 0 { + v := int64(item.VenueSiteID) + s.VenueSiteId = &v + } + if item.SpaceID != 0 { + v := int64(item.SpaceID) + s.SpaceId = &v + } + if item.TimeID != 0 { + v := int64(item.TimeID) + s.TimeId = &v + } + if item.Token != "" { + s.Token = &item.Token + } + if item.WeekStart != "" { + if d, parseErr := time.Parse("2006-01-02", item.WeekStart); parseErr == nil { + dt := openapi_types.Date{Time: d} + s.WeekStartDate = &dt + } + } + resp.Items = append(resp.Items, s) } response.JSON(c, http.StatusOK, resp) } -func (h *Handler) PreviewRoomReservation(c *gin.Context, roomPublicId gen.RoomPublicIdPath) { +func (h *Handler) PreviewRoomReservation(c *gin.Context, roomId gen.RoomPublicIdPath) { var req gen.ReservationSubmitRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "invalid request body") return } - room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomPublicId.String()) + room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomId.String()) if roomErr != nil { response.Error(c, http.StatusNotFound, "room not found") return @@ -399,13 +433,13 @@ func (h *Handler) PreviewRoomReservation(c *gin.Context, roomPublicId gen.RoomPu response.JSON(c, http.StatusOK, buildReservationPreviewResponse(preview, room.PublicID)) } -func (h *Handler) SubmitRoomReservation(c *gin.Context, roomPublicId gen.RoomPublicIdPath) { +func (h *Handler) SubmitRoomReservation(c *gin.Context, roomId gen.RoomPublicIdPath) { var req gen.ReservationSubmitRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "invalid request body") return } - room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomPublicId.String()) + room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomId.String()) if roomErr != nil { response.Error(c, http.StatusNotFound, "room not found") return @@ -446,13 +480,13 @@ func (h *Handler) ListReservationTemplates(c *gin.Context, params gen.ListReserv }) } -func (h *Handler) CreateRoomReservationPlan(c *gin.Context, roomPublicId gen.RoomPublicIdPath) { +func (h *Handler) CreateRoomReservationPlan(c *gin.Context, roomId gen.RoomPublicIdPath) { var req gen.ReservationPlanRequest if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "invalid request body") return } - room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomPublicId.String()) + room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomId.String()) if roomErr != nil { response.Error(c, http.StatusNotFound, "room not found") return diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 504e677..a890112 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -22,14 +22,16 @@ type ReservationVenueItem struct { } type ReservationSlotItem struct { - SlotKey string - StartTime string - EndTime string - Available bool - SpaceName *string - // Internal fields populated from dayInfo response; not exposed via API. + SlotKey string + StartTime string + EndTime string + Available bool + CampusName string + VenueName string + VenueID *uint VenueSiteID uint SpaceID uint + SpaceName *string TimeID uint Token string WeekStart string @@ -233,71 +235,74 @@ func textMatches(got, want string) bool { } func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusName, venueName, reservationDate string) ([]ReservationSlotItem, error) { - // Step 1: Get venue info to find venueId and venueSiteId + // Step 1: 从 VenueInfo 找到 venueId / venueSiteId,同时解析 spaceId 列表供降级用。 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 + var venueIDStr, venueSiteIDStr string + var resolvedCampus, resolvedVenue 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) { + if !textMatches(sportGot, sportType) || !textMatches(campusGot, campusName) || !textMatches(venueGot, venueName) { return } - if !textMatches(campusGot, campusName) { - return - } - if !textMatches(venueGot, venueName) { - return + if venueIDStr == "" { + venueIDStr = trimString(obj["venueId"]) + venueSiteIDStr = trimString(obj["id"]) + resolvedCampus = campusGot + resolvedVenue = venueGot } - venueID = trimString(obj["venueId"]) - venueSiteID = trimString(obj["id"]) }) - if venueID == "" || venueSiteID == "" { + if venueIDStr == "" || venueSiteIDStr == "" { return nil, fmt.Errorf("venue not found for sport=%s campus=%s venue=%s", sportType, campusName, venueName) } - // Step 2: Get day info (available slots) + venueIDUint := parseUint(venueIDStr) + venueSiteIDUint := parseUint(venueSiteIDStr) + + // Step 2: 尝试从 TYYS 获取实时 dayInfo。 params := url.Values{} - params.Set("venueId", venueID) - params.Set("venueSiteId", venueSiteID) - params.Set("siteId", venueSiteID) + params.Set("venueId", venueIDStr) + params.Set("venueSiteId", venueSiteIDStr) + params.Set("siteId", venueSiteIDStr) 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) + dayResp, dayErr := s.tyys.ReservationDayInfo(ctx, params) + + // Step 3: 若 dayInfo 失败(预约窗口未开放),降级为 template 数据,标记 available=false。 + if dayErr != nil || dayResp == nil { + return s.listSlotsFromTemplate(ctx, resolvedCampus, resolvedVenue, venueIDUint, venueSiteIDUint, nil), nil } - // Step 3: Extract top-level token/weekStartDate from dayInfo (not per-slot). + // Step 4: 解析 dayInfo,提取顶层 token/weekStartDate。 var topToken, topWeekStart string var topObj map[string]any if unmarshalErr := json.Unmarshal(dayResp.Data, &topObj); unmarshalErr == nil { topToken = trimString(topObj["token"]) topWeekStart = trimString(topObj["weekStartDate"]) } - venueSiteIDUint := parseUint(venueSiteID) - // Step 4: Parse slots, carrying internal IDs for use by materializeOne. + // Step 5: 按 slot 构建完整上下文。 var slots []ReservationSlotItem walkSlots(dayResp.Data, func(slot map[string]any) { + spaceID := parseUint(trimString(slot["spaceId"])) item := ReservationSlotItem{ SlotKey: trimString(slot["timeId"]), StartTime: trimString(slot["startDate"]), EndTime: trimString(slot["endDate"]), Available: isSlotAvailable(slot), + CampusName: resolvedCampus, + VenueName: resolvedVenue, + VenueID: &venueIDUint, VenueSiteID: venueSiteIDUint, - SpaceID: parseUint(trimString(slot["spaceId"])), + SpaceID: spaceID, TimeID: parseUint(trimString(slot["timeId"])), Token: coalesce(trimString(slot["token"]), topToken), WeekStart: coalesce(trimString(slot["weekStartDate"]), topWeekStart), @@ -311,6 +316,59 @@ func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusNam return slots, nil } +// listSlotsFromTemplate 在 TYYS 预约窗口未开放时,基于 template 数据返回时间段骨架。 +// 所有 slot 标记 available=false,token/week_start 为空,供前端构建计划预约的首选场次。 +// 用 tmpl.Spaces 做分场列表(不依赖 VenueInfo 层是否携带 spaceId)。 +func (s *ReservationService) listSlotsFromTemplate(ctx context.Context, campusName, venueName string, venueID, venueSiteID uint, _ map[uint]string) []ReservationSlotItem { + tmpl, err := s.ListTemplates(ctx, "", campusName, venueName) + if err != nil || tmpl == nil { + return nil + } + + // 优先用 template 的 VenueID / VenueSiteID(更准确) + effectiveVenueID := venueID + effectiveVenueSiteID := venueSiteID + if tmpl.VenueID != nil { + effectiveVenueID = *tmpl.VenueID + } + if tmpl.VenueSiteID != nil { + effectiveVenueSiteID = *tmpl.VenueSiteID + } + + spaces := tmpl.Spaces + // 若 template 没有分场信息(场馆不区分小场地),用空占位保证时间段仍能返回 + if len(spaces) == 0 { + spaces = []TemplateSpace{{}} + } + + var slots []ReservationSlotItem + for _, sp := range spaces { + sn := sp.SpaceName + for _, ts := range tmpl.TimeSlots { + vid := effectiveVenueID + item := ReservationSlotItem{ + Available: false, + CampusName: campusName, + VenueName: venueName, + VenueID: &vid, + VenueSiteID: effectiveVenueSiteID, + SpaceID: sp.SpaceID, + StartTime: ts.StartTime, + EndTime: ts.EndTime, + } + if sn != "" { + item.SpaceName = &sn + } + if ts.TimeID != nil { + item.TimeID = *ts.TimeID + item.SlotKey = strconv.FormatUint(uint64(*ts.TimeID), 10) + } + slots = append(slots, item) + } + } + return slots +} + // 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 { From fad5f43ecf0f7baa213ec4c07c0caf8bce91991a Mon Sep 17 00:00:00 2001 From: GP Date: Wed, 6 May 2026 10:00:34 +0800 Subject: [PATCH 7/8] fix id problem --- dayinfo_debug.go | 41 ++++++++++++++++++++ internal/service/reservation_service.go | 50 ++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 dayinfo_debug.go diff --git a/dayinfo_debug.go b/dayinfo_debug.go new file mode 100644 index 0000000..9eb2039 --- /dev/null +++ b/dayinfo_debug.go @@ -0,0 +1,41 @@ +//go:build ignore + +package main + +import ( + "context" + "fmt" + "net/url" + "time" + + zjulogin "github.com/QSCTech/SRTP-Backend/internal/zjulogin" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + auth, err := zjulogin.NewFromEnv() + if err != nil { + panic(err) + } + tyys, err := auth.TYYS() + if err != nil { + panic(err) + } + + params := url.Values{} + params.Set("venueId", "22") + params.Set("venueSiteId", "23") + params.Set("siteId", "23") + params.Set("date", "2026-05-08") + params.Set("reservationDate", "2026-05-08") + params.Set("searchDate", "2026-05-08") + params.Set("weekStartDate", "2026-05-08") + + resp, err := tyys.ReservationDayInfo(ctx, params) + if err != nil { + panic(err) + } + fmt.Printf("%s\n", string(resp.Data)) +} diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index a890112..3b05145 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -383,18 +383,56 @@ func isSlotAvailable(slot map[string]any) bool { return true } -// walkSlots walks through parsed JSON and visits each slot object. +// walkSlots walks through parsed TYYS dayInfo JSON and visits each slot with its space context. +// +// TYYS dayInfo structure: space objects contain child objects keyed by timeId, where each +// child has startDate/endDate. spaceId comes from the parent's "id" field; timeId is the key. +// This function merges spaceId/spaceName/timeId into each slot before calling visit. 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) + walkSlotsAny(payload, visit) +} + +func walkSlotsAny(v any, visit func(map[string]any)) { + switch node := v.(type) { + case map[string]any: + spaceID := trimString(node["id"]) + spaceName := trimString(node["spaceName"]) + foundSlot := false + if spaceID != "" { + for key, val := range node { + child, ok := val.(map[string]any) + if !ok { + continue + } + if _, hasStart := child["startDate"]; !hasStart { + continue + } + // merge space context into slot + slot := make(map[string]any, len(child)+3) + for k, v := range child { + slot[k] = v + } + slot["spaceId"] = spaceID + slot["spaceName"] = spaceName + slot["timeId"] = key + visit(slot) + foundSlot = true + } } - }) + if !foundSlot { + for _, child := range node { + walkSlotsAny(child, visit) + } + } + case []any: + for _, item := range node { + walkSlotsAny(item, visit) + } + } } // venueOpenHour 返回指定校区+球类的 TYYS 预约开放小时(Asia/Shanghai)。 From 0a9a1d1ce25d8975a67be987d034c647ff2a366b Mon Sep 17 00:00:00 2001 From: GP Date: Wed, 6 May 2026 11:11:44 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E9=87=8D=E6=9E=84=20template/plan/material?= =?UTF-8?q?ize=20=E9=A2=84=E7=BA=A6=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - walkSlots 修复:正确解析 TYYS dayInfo 中 spaceId(父对象 id)和 timeId(子对象 key) - ListTemplates 改为调 ListSlots(tomorrow) 并剥离实时字段,返回扁平 space×timeslot 列表 - ReservationTemplateResponse 用 slots[] 替换原 spaces[]+time_slots[] 两级结构 - SubmitOrPlan:对开放窗口内 slot 依次尝试实时提交,全败降级 CreatePlan - SubmitRoomReservation handler 改走 SubmitOrPlan - Preview 对窗口未开放的 slot 跳过 TYYS 调用,直接标 available=false 并说明开放时间 - resolvePreferredSlots 修复:同 space 多时段不再互相覆盖,匹配时加时间段过滤 Co-Authored-By: Claude Sonnet 4.6 --- api/openapi/openapi.yaml | 29 +-- internal/api/gen/api.gen.go | 42 ++-- internal/api/handler.go | 49 ++-- internal/service/reservation_service.go | 313 +++++++++++++----------- 4 files changed, 236 insertions(+), 197 deletions(-) diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 75bd36f..bb3091b 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -1491,22 +1491,26 @@ components: updated_at: type: string format: date-time - ReservationTemplateSpace: + ReservationTemplateSlot: type: object - required: [space_id, space_name] + required: [space_id, space_name, start_time, end_time, display_label] + description: 场馆固定场次(space × timeslot),不含实时的 time_id/token properties: space_id: type: integer format: int64 space_name: type: string - ReservationTemplateTimeSlot: - type: object - required: [start_time, end_time, display_label] - properties: - time_id: + venue_id: + type: integer + format: int64 + venue_site_id: type: integer format: int64 + campus_name: + type: string + venue_name: + type: string start_time: type: string example: "08:00" @@ -1518,7 +1522,7 @@ components: example: "08:00-09:00" ReservationTemplateResponse: type: object - required: [sport_type, campus_name, venue_name, venue_site_id, spaces, time_slots] + required: [sport_type, campus_name, venue_name, venue_site_id, slots] description: 场馆固定结构信息,用于创建预约计划时选择时间段 properties: sport_type: @@ -1533,14 +1537,11 @@ components: venue_site_id: type: integer format: int64 - spaces: - type: array - items: - $ref: '#/components/schemas/ReservationTemplateSpace' - time_slots: + slots: type: array + description: 所有可选场次(space × timeslot 笛卡尔积),不含实时的 time_id/token items: - $ref: '#/components/schemas/ReservationTemplateTimeSlot' + $ref: '#/components/schemas/ReservationTemplateSlot' ReservationPlanSlotSelection: type: object required: [campus_name, venue_name, space_id, start_time, end_time] diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index 5af7e26..ddced9d 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -254,27 +254,27 @@ type ReservationSubmitRequest struct { // ReservationTemplateResponse 场馆固定结构信息,用于创建预约计划时选择时间段 type ReservationTemplateResponse struct { - CampusName string `json:"campus_name"` - Spaces []ReservationTemplateSpace `json:"spaces"` - SportType string `json:"sport_type"` - TimeSlots []ReservationTemplateTimeSlot `json:"time_slots"` - VenueId *int64 `json:"venue_id,omitempty"` - VenueName string `json:"venue_name"` - VenueSiteId int64 `json:"venue_site_id"` -} - -// ReservationTemplateSpace defines model for ReservationTemplateSpace. -type ReservationTemplateSpace struct { - SpaceId int64 `json:"space_id"` - SpaceName string `json:"space_name"` -} - -// ReservationTemplateTimeSlot defines model for ReservationTemplateTimeSlot. -type ReservationTemplateTimeSlot struct { - DisplayLabel string `json:"display_label"` - EndTime string `json:"end_time"` - StartTime string `json:"start_time"` - TimeId *int64 `json:"time_id,omitempty"` + CampusName string `json:"campus_name"` + + // Slots 所有可选场次(space × timeslot 笛卡尔积),不含实时的 time_id/token + Slots []ReservationTemplateSlot `json:"slots"` + SportType string `json:"sport_type"` + VenueId *int64 `json:"venue_id,omitempty"` + VenueName string `json:"venue_name"` + VenueSiteId int64 `json:"venue_site_id"` +} + +// ReservationTemplateSlot 场馆固定场次(space × timeslot),不含实时的 time_id/token +type ReservationTemplateSlot struct { + CampusName *string `json:"campus_name,omitempty"` + DisplayLabel string `json:"display_label"` + EndTime string `json:"end_time"` + SpaceId int64 `json:"space_id"` + SpaceName string `json:"space_name"` + StartTime string `json:"start_time"` + VenueId *int64 `json:"venue_id,omitempty"` + VenueName *string `json:"venue_name,omitempty"` + VenueSiteId *int64 `json:"venue_site_id,omitempty"` } // ReservationTriggerRequest 触发单条预约提交(供调度器调用,使用 public_id 避免整数枚举越权风险) diff --git a/internal/api/handler.go b/internal/api/handler.go index 7d91f83..e5c07e7 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -444,12 +444,12 @@ func (h *Handler) SubmitRoomReservation(c *gin.Context, roomId gen.RoomPublicIdP response.Error(c, http.StatusNotFound, "room not found") return } - reservation, err := h.reservationService.Submit(c.Request.Context(), buildReservationPreviewInput(room.ID, req)) + result, err := h.reservationService.SubmitOrPlan(c.Request.Context(), buildReservationPreviewInput(room.ID, req)) if err != nil { response.Error(c, http.StatusBadRequest, err.Error()) return } - response.JSON(c, http.StatusOK, buildReservationRecordResponse(reservation, room.PublicID)) + response.JSON(c, http.StatusOK, buildReservationRecordResponse(result.Record, room.PublicID)) } func (h *Handler) ListReservationTemplates(c *gin.Context, params gen.ListReservationTemplatesParams) { @@ -458,25 +458,38 @@ func (h *Handler) ListReservationTemplates(c *gin.Context, params gen.ListReserv response.Error(c, http.StatusBadRequest, err.Error()) return } - spaces := make([]gen.ReservationTemplateSpace, 0, len(out.Spaces)) - for _, sp := range out.Spaces { - spaces = append(spaces, gen.ReservationTemplateSpace{SpaceId: uint64ToInt64(sp.SpaceID), SpaceName: sp.SpaceName}) + slots := make([]gen.ReservationTemplateSlot, 0, len(out.Slots)) + for _, ts := range out.Slots { + s := gen.ReservationTemplateSlot{ + SpaceId: uint64ToInt64(ts.SpaceID), + SpaceName: ts.SpaceName, + CampusName: stringPtr(ts.CampusName), + VenueName: stringPtr(ts.VenueName), + StartTime: ts.StartTime, + EndTime: ts.EndTime, + DisplayLabel: ts.DisplayLabel, + } + if ts.VenueID != nil { + v := int64(*ts.VenueID) + s.VenueId = &v + } + if ts.VenueSiteID != 0 { + v := int64(ts.VenueSiteID) + s.VenueSiteId = &v + } + slots = append(slots, s) } - timeSlots := make([]gen.ReservationTemplateTimeSlot, 0, len(out.TimeSlots)) - for _, ts := range out.TimeSlots { - timeSlots = append(timeSlots, gen.ReservationTemplateTimeSlot{ - TimeId: uintPtrToInt64Ptr(ts.TimeID), - StartTime: ts.StartTime, - EndTime: ts.EndTime, - }) + venueSiteID := int64(0) + if out.VenueSiteID != nil { + venueSiteID = int64(*out.VenueSiteID) } response.JSON(c, http.StatusOK, gen.ReservationTemplateResponse{ - SportType: out.SportType, - CampusName: out.CampusName, - VenueName: out.VenueName, - VenueId: uintPtrToInt64Ptr(out.VenueID), - Spaces: spaces, - TimeSlots: timeSlots, + SportType: out.SportType, + CampusName: out.CampusName, + VenueName: out.VenueName, + VenueId: uintPtrToInt64Ptr(out.VenueID), + VenueSiteId: venueSiteID, + Slots: slots, }) } diff --git a/internal/service/reservation_service.go b/internal/service/reservation_service.go index 3b05145..6301ec1 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -53,17 +53,16 @@ type SlotSelection struct { WeekStart string } -// TemplateSpace 是场馆固定分场信息。 -type TemplateSpace struct { - SpaceID uint - SpaceName string -} - -// TemplateTimeSlot 是场馆固定时间段模板。 -type TemplateTimeSlot struct { - TimeID *uint - StartTime string - EndTime string +// TemplateSlot 是场馆固定场次(space × timeslot),不含实时的 TimeID/Token。 +type TemplateSlot struct { + SpaceID uint + SpaceName string + VenueID *uint + VenueSiteID uint + CampusName string + VenueName string + StartTime string + EndTime string DisplayLabel string } @@ -74,8 +73,7 @@ type ReservationTemplateOutput struct { VenueName string VenueID *uint VenueSiteID *uint - Spaces []TemplateSpace - TimeSlots []TemplateTimeSlot + Slots []TemplateSlot } type ReservationPreviewInput struct { @@ -317,54 +315,37 @@ func (s *ReservationService) ListSlots(ctx context.Context, sportType, campusNam } // listSlotsFromTemplate 在 TYYS 预约窗口未开放时,基于 template 数据返回时间段骨架。 -// 所有 slot 标记 available=false,token/week_start 为空,供前端构建计划预约的首选场次。 -// 用 tmpl.Spaces 做分场列表(不依赖 VenueInfo 层是否携带 spaceId)。 +// 所有 slot 标记 available=false,token/time_id 为空,供前端构建计划预约的首选场次。 func (s *ReservationService) listSlotsFromTemplate(ctx context.Context, campusName, venueName string, venueID, venueSiteID uint, _ map[uint]string) []ReservationSlotItem { tmpl, err := s.ListTemplates(ctx, "", campusName, venueName) if err != nil || tmpl == nil { return nil } - - // 优先用 template 的 VenueID / VenueSiteID(更准确) - effectiveVenueID := venueID - effectiveVenueSiteID := venueSiteID - if tmpl.VenueID != nil { - effectiveVenueID = *tmpl.VenueID - } - if tmpl.VenueSiteID != nil { - effectiveVenueSiteID = *tmpl.VenueSiteID - } - - spaces := tmpl.Spaces - // 若 template 没有分场信息(场馆不区分小场地),用空占位保证时间段仍能返回 - if len(spaces) == 0 { - spaces = []TemplateSpace{{}} - } - var slots []ReservationSlotItem - for _, sp := range spaces { - sn := sp.SpaceName - for _, ts := range tmpl.TimeSlots { - vid := effectiveVenueID - item := ReservationSlotItem{ - Available: false, - CampusName: campusName, - VenueName: venueName, - VenueID: &vid, - VenueSiteID: effectiveVenueSiteID, - SpaceID: sp.SpaceID, - StartTime: ts.StartTime, - EndTime: ts.EndTime, - } - if sn != "" { - item.SpaceName = &sn - } - if ts.TimeID != nil { - item.TimeID = *ts.TimeID - item.SlotKey = strconv.FormatUint(uint64(*ts.TimeID), 10) - } - slots = append(slots, item) + for _, ts := range tmpl.Slots { + vid := venueID + if ts.VenueID != nil { + vid = *ts.VenueID + } + sid := venueSiteID + if ts.VenueSiteID != 0 { + sid = ts.VenueSiteID + } + item := ReservationSlotItem{ + Available: false, + CampusName: ts.CampusName, + VenueName: ts.VenueName, + VenueID: &vid, + VenueSiteID: sid, + SpaceID: ts.SpaceID, + StartTime: ts.StartTime, + EndTime: ts.EndTime, + } + if ts.SpaceName != "" { + sn := ts.SpaceName + item.SpaceName = &sn } + slots = append(slots, item) } return slots } @@ -475,12 +456,79 @@ func reserveOpenAt(reservationDate, campusName, sportType string) (time.Time, er return time.Date(openDate.Year(), openDate.Month(), openDate.Day(), hour, 0, 0, 0, loc), nil } -// ListTemplates 查询场馆固定结构信息(分场列表和时间段模板)。 -// 先用 VenueInfo 取分场,再尝试用明日的 dayInfo 取时间段模板;dayInfo 失败时只返回分场。 +// ReservationSubmitOrPlanResult 是 SubmitOrPlan 的返回值,标明实际走了哪条路径。 +type ReservationSubmitOrPlanResult struct { + Record *models.RoomReservation + Planned bool // true=创建了计划,false=直接提交 +} + +// SubmitOrPlan 统一预约入口:对开放窗口内的 slot 依次尝试实时提交,全部失败(或无窗口内 slot)时降级为创建远期计划。 +// 入参统一使用 ReservationPreviewInput;计划路径内部自动将 SlotSelection 降级为 PlanSlotSelection。 +func (s *ReservationService) SubmitOrPlan(ctx context.Context, input ReservationPreviewInput) (*ReservationSubmitOrPlanResult, error) { + if len(input.Slots) == 0 { + return nil, fmt.Errorf("submit or plan requires at least one slot selection") + } + + now := time.Now() + var openSlots []SlotSelection + for _, slot := range input.Slots { + openAt, err := reserveOpenAt(input.ReservationDate, slot.CampusName, input.SportType) + if err != nil { + continue + } + if !now.Before(openAt) { + openSlots = append(openSlots, slot) + } + } + + if len(openSlots) > 0 { + record, err := s.Submit(ctx, ReservationPreviewInput{ + RoomID: input.RoomID, + SportType: input.SportType, + ReservationDate: input.ReservationDate, + BuddyCode: input.BuddyCode, + Slots: openSlots, + }) + if err == nil && record.ReservationStatus == "success" { + return &ReservationSubmitOrPlanResult{Record: record, Planned: false}, nil + } + } + + // 无开放窗口内的 slot,或全部提交失败,降级为计划 + planSlots := make([]PlanSlotSelection, 0, len(input.Slots)) + for _, s := range input.Slots { + siteID := s.VenueSiteID + planSlots = append(planSlots, PlanSlotSelection{ + CampusName: s.CampusName, + VenueName: s.VenueName, + VenueID: s.VenueID, + VenueSiteID: &siteID, + SpaceID: s.SpaceID, + SpaceName: s.SpaceName, + StartTime: s.StartTime, + EndTime: s.EndTime, + }) + } + record, err := s.CreatePlan(ctx, ReservationPlanInput{ + RoomID: input.RoomID, + SportType: input.SportType, + ReservationDate: input.ReservationDate, + BuddyCode: input.BuddyCode, + PlanSlots: planSlots, + }) + if err != nil { + return nil, err + } + return &ReservationSubmitOrPlanResult{Record: record, Planned: true}, nil +} + +// ListTemplates 查询场馆固定场次列表,不含实时的 TimeID/Token。 +// 用明日日期调 ListSlots,剥离实时字段后返回。 func (s *ReservationService) ListTemplates(ctx context.Context, sportType, campusName, venueName string) (*ReservationTemplateOutput, error) { - venueResp, err := s.tyys.VenueInfo(ctx, 0) + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + items, err := s.ListSlots(ctx, sportType, campusName, venueName, tomorrow) if err != nil { - return nil, fmt.Errorf("get venue info: %w", err) + return nil, err } out := &ReservationTemplateOutput{ @@ -488,74 +536,28 @@ func (s *ReservationService) ListTemplates(ctx context.Context, sportType, campu CampusName: campusName, VenueName: venueName, } - - spaceSet := map[uint]string{} - walkVenues(venueResp.Data, func(obj map[string]any) { - if !textMatches(trimString(obj["sportName"]), sportType) { - return - } - if !textMatches(trimString(obj["campusName"]), campusName) { - return - } - if !textMatches(trimString(obj["venueName"]), venueName) { - return - } - if out.VenueID == nil { - if vid := trimString(obj["venueId"]); vid != "" { - v := parseUint(vid) - out.VenueID = &v - } - } - if out.VenueSiteID == nil { - if sid := trimString(obj["id"]); sid != "" { - v := parseUint(sid) - out.VenueSiteID = &v - } + if len(items) > 0 { + out.CampusName = items[0].CampusName + out.VenueName = items[0].VenueName + out.VenueID = items[0].VenueID + if items[0].VenueSiteID != 0 { + v := items[0].VenueSiteID + out.VenueSiteID = &v } - if spaceID := trimString(obj["spaceId"]); spaceID != "" { - id := parseUint(spaceID) - spaceSet[id] = trimString(obj["spaceName"]) - } - }) + } - for id, name := range spaceSet { - out.Spaces = append(out.Spaces, TemplateSpace{SpaceID: id, SpaceName: name}) - } - - // 尝试从明日的 dayInfo 取时间段模板;失败不影响主结果。 - if out.VenueID != nil && out.VenueSiteID != nil { - tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") - params := url.Values{} - params.Set("venueId", strconv.FormatUint(uint64(*out.VenueID), 10)) - params.Set("venueSiteId", strconv.FormatUint(uint64(*out.VenueSiteID), 10)) - params.Set("siteId", strconv.FormatUint(uint64(*out.VenueSiteID), 10)) - params.Set("date", tomorrow) - params.Set("reservationDate", tomorrow) - params.Set("searchDate", tomorrow) - if dayResp, dayErr := s.tyys.ReservationDayInfo(ctx, params); dayErr == nil { - slotSet := map[string]TemplateTimeSlot{} - walkSlots(dayResp.Data, func(slot map[string]any) { - start := trimString(slot["startDate"]) - end := trimString(slot["endDate"]) - key := start + "|" + end - if _, exists := slotSet[key]; exists { - return - } - ts := TemplateTimeSlot{ - StartTime: formatTimeOnly(start), - EndTime: formatTimeOnly(end), - DisplayLabel: formatTimeOnly(start) + "-" + formatTimeOnly(end), - } - if tid := trimString(slot["timeId"]); tid != "" { - v := parseUint(tid) - ts.TimeID = &v - } - slotSet[key] = ts - }) - for _, ts := range slotSet { - out.TimeSlots = append(out.TimeSlots, ts) - } - } + for _, item := range items { + out.Slots = append(out.Slots, TemplateSlot{ + SpaceID: item.SpaceID, + SpaceName: stringVal(item.SpaceName), + VenueID: item.VenueID, + VenueSiteID: item.VenueSiteID, + CampusName: item.CampusName, + VenueName: item.VenueName, + StartTime: formatTimeOnly(item.StartTime), + EndTime: formatTimeOnly(item.EndTime), + DisplayLabel: formatTimeOnly(item.StartTime) + "-" + formatTimeOnly(item.EndTime), + }) } return out, nil @@ -612,7 +614,17 @@ func (s *ReservationService) Preview(ctx context.Context, input ReservationPrevi BuddyCode: input.BuddyCode, } + now := time.Now() for _, slot := range input.Slots { + openAt, err := reserveOpenAt(input.ReservationDate, slot.CampusName, input.SportType) + if err == nil && now.Before(openAt) { + out.Slots = append(out.Slots, SlotPreviewItem{ + Slot: slot, + Available: false, + Error: fmt.Sprintf("reservation window not open until %s", openAt.Format("2006-01-02 15:04")), + }) + continue + } form := url.Values{} form.Set("venueSiteId", strconv.FormatUint(uint64(slot.VenueSiteID), 10)) form.Set("reservationDate", input.ReservationDate) @@ -623,10 +635,10 @@ func (s *ReservationService) Preview(ctx context.Context, input ReservationPrevi "timeId": strconv.FormatUint(uint64(slot.TimeID), 10), "venueSpaceGroupId": nil, }})) - _, err := s.tyys.ReservationOrderInfo(ctx, form) - item := SlotPreviewItem{Slot: slot, Available: err == nil} - if err != nil { - item.Error = err.Error() + _, tyysErr := s.tyys.ReservationOrderInfo(ctx, form) + item := SlotPreviewItem{Slot: slot, Available: tyysErr == nil} + if tyysErr != nil { + item.Error = tyysErr.Error() } out.Slots = append(out.Slots, item) } @@ -733,31 +745,44 @@ func (s *ReservationService) resolvePreferredSlots(ctx context.Context, plan *mo if err != nil { continue // 该场馆暂不可查,跳过 } - spaceMap := make(map[uint]PlanSlotSelection, len(group)) + // 同一 space 可能有多个时段,用 []PlanSlotSelection 保留全部意图 + spaceMap := make(map[uint][]PlanSlotSelection, len(group)) for _, ps := range group { - spaceMap[ps.SpaceID] = ps + spaceMap[ps.SpaceID] = append(spaceMap[ps.SpaceID], ps) } for _, live := range liveSlots { if !live.Available { continue } - ps, ok := spaceMap[live.SpaceID] + preferred, ok := spaceMap[live.SpaceID] if !ok { continue } - candidates = append(candidates, SlotSelection{ - CampusName: key.campus, - VenueName: key.venue, - VenueID: ps.VenueID, - VenueSiteID: live.VenueSiteID, - SpaceID: live.SpaceID, - SpaceName: live.SpaceName, - StartTime: live.StartTime, - EndTime: live.EndTime, - TimeID: live.TimeID, - Token: live.Token, - WeekStart: live.WeekStart, - }) + // 按时间段匹配:live slot 的 start/end 必须与某条意图一致 + for _, ps := range preferred { + wantStart := formatTimeOnly(ps.StartTime) + wantEnd := formatTimeOnly(ps.EndTime) + if wantStart != "" && wantStart != formatTimeOnly(live.StartTime) { + continue + } + if wantEnd != "" && wantEnd != formatTimeOnly(live.EndTime) { + continue + } + candidates = append(candidates, SlotSelection{ + CampusName: key.campus, + VenueName: key.venue, + VenueID: ps.VenueID, + VenueSiteID: live.VenueSiteID, + SpaceID: live.SpaceID, + SpaceName: live.SpaceName, + StartTime: live.StartTime, + EndTime: live.EndTime, + TimeID: live.TimeID, + Token: live.Token, + WeekStart: live.WeekStart, + }) + break // 一条 live slot 匹配一条意图即可 + } } } return candidates, nil