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 f12ed17..bb3091b 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 @@ -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 @@ -543,7 +565,7 @@ paths: operationId: previewRoomReservation summary: Preview reservation for a room parameters: - - $ref: '#/components/parameters/RoomIdPath' + - $ref: '#/components/parameters/RoomPublicIdPath' requestBody: required: true content: @@ -568,7 +590,7 @@ paths: operationId: submitRoomReservation summary: Submit reservation for a room parameters: - - $ref: '#/components/parameters/RoomIdPath' + - $ref: '#/components/parameters/RoomPublicIdPath' requestBody: required: true content: @@ -588,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 天的相应时间 自动补全 slot 上下文并提交 TYYS。 + parameters: + - $ref: '#/components/parameters/RoomPublicIdPath' + 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: @@ -606,6 +744,13 @@ components: type: integer format: int64 minimum: 1 + RoomPublicIdPath: + in: path + name: roomId + required: true + schema: + type: string + format: uuid MemberUserIdPath: in: path name: userId @@ -1082,13 +1227,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] @@ -1100,17 +1249,50 @@ 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: 是否可预约;预约窗口未开放时为 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: 场地名称 + 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] @@ -1119,30 +1301,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 @@ -1155,19 +1373,33 @@ components: format: int64 space_name: type: string + start_time: + type: string + end_time: + type: string + time_id: + type: integer + format: int64 + token: + type: string + week_start_date: + type: string + format: 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 @@ -1177,34 +1409,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: @@ -1275,3 +1491,133 @@ components: updated_at: type: string format: date-time + ReservationTemplateSlot: + type: object + 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 + 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" + 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, 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 + slots: + type: array + description: 所有可选场次(space × timeslot 笛卡尔积),不含实时的 time_id/token + items: + $ref: '#/components/schemas/ReservationTemplateSlot' + 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 + - reservation_date + - plan_slots + description: 创建预约计划请求(仅保存预约意图,不立即调 TYYS) + properties: + sport_type: + type: string + reservation_date: + type: string + format: date + buddy_code: + type: string + plan_slots: + type: array + items: + $ref: '#/components/schemas/ReservationPlanSlotSelection' + description: 选中场次列表,campus/venue/start/end 均在每个 slot 中携带;调度器依次尝试直到一个成功 + ReservationTriggerRequest: + type: object + required: [reservation_public_id] + description: 触发单条预约提交(供调度器调用,使用 public_id 避免整数枚举越权风险) + properties: + reservation_public_id: + type: string + format: uuid + ReservationMaterializeRequest: + type: object + description: | + 触发预约计划补全(供调度器调用)。 + 后端自动查找 reserve_open_at <= now 且 status = scheduled 的记录,无需传入日期。 + properties: + dry_run: + type: boolean + description: 若为 true,只返回待处理计划数量,不实际执行 + ReservationMaterializeResult: + type: object + required: [total, succeeded, failed, expired] + description: 预约计划批量补全结果,返回本次调度处理的计划数量及失败详情 + properties: + total: + type: integer + succeeded: + type: integer + failed: + type: integer + expired: + type: integer + description: 本次清理中标记为 expired 的过期失败预约数量 + errors: + type: array + items: + type: string diff --git a/cmd/server/main.go b/cmd/server/main.go index 68bb937..a0c629a 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" @@ -79,7 +80,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/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/api/gen/api.gen.go b/internal/api/gen/api.gen.go index d04cd43..ddced9d 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -90,23 +90,77 @@ 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"` + + // 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 []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. @@ -134,13 +188,36 @@ 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 是否可预约;预约窗口未开放时为 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 string `json:"slot_key"` + SpaceId *int64 `json:"space_id,omitempty"` + + // SpaceName 场地名称 SpaceName *string `json:"space_name,omitempty"` - StartTime string `json:"start_time"` + + // 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. @@ -148,26 +225,73 @@ 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"` - VenueId *int64 `json:"venue_id,omitempty"` - VenueName string `json:"venue_name"` - VenueSiteId *int64 `json:"venue_site_id,omitempty"` + + // Slots 候选场地列表,campus/venue/start/end 等上下文均在每个 slot 中携带 + Slots []ReservationSlotSelection `json:"slots"` + SportType string `json:"sport_type"` +} + +// ReservationTemplateResponse 场馆固定结构信息,用于创建预约计划时选择时间段 +type ReservationTemplateResponse struct { + 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"` } -// ReservationVenue defines model for ReservationVenue. +// ReservationTriggerRequest 触发单条预约提交(供调度器调用,使用 public_id 避免整数枚举越权风险) +type ReservationTriggerRequest struct { + ReservationPublicId openapi_types.UUID `json:"reservation_public_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. @@ -322,6 +446,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 @@ -339,16 +466,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. @@ -367,6 +511,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 @@ -391,6 +541,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 @@ -411,6 +564,12 @@ 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) @@ -429,9 +588,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) @@ -471,12 +633,15 @@ 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 RoomPublicIdPath) // Preview reservation for a room // (POST /rooms/{roomId}/reservation/preview) - PreviewRoomReservation(c *gin.Context, roomId RoomIdPath) + PreviewRoomReservation(c *gin.Context, roomId RoomPublicIdPath) // Submit reservation for a room // (POST /rooms/{roomId}/reservation/submit) - SubmitRoomReservation(c *gin.Context, roomId RoomIdPath) + SubmitRoomReservation(c *gin.Context, roomId RoomPublicIdPath) // Create a user record // (POST /users) CreateUser(c *gin.Context) @@ -533,6 +698,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) { @@ -731,6 +922,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) { @@ -1106,15 +1360,39 @@ 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 RoomPublicIdPath + + 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 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) { var err error // ------------- Path parameter "roomId" ------------- - var roomId RoomIdPath + var roomId RoomPublicIdPath - err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "integer", Format: "int64"}) + 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 roomId: %w", err), http.StatusBadRequest) return @@ -1136,9 +1414,9 @@ func (siw *ServerInterfaceWrapper) SubmitRoomReservation(c *gin.Context) { var err error // ------------- Path parameter "roomId" ------------- - var roomId RoomIdPath + var roomId RoomPublicIdPath - err = runtime.BindStyledParameterWithOptions("simple", "roomId", c.Param("roomId"), &roomId, runtime.BindStyledParameterOptions{Explode: false, Required: true, Type: "integer", Format: "int64"}) + 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 roomId: %w", err), http.StatusBadRequest) return @@ -1221,6 +1499,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) @@ -1228,6 +1508,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) @@ -1241,6 +1522,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/internal/api/handler.go b/internal/api/handler.go index 31a142e..e5c07e7 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -375,42 +375,194 @@ 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, roomId int64) { +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 } - preview, err := h.reservationService.Preview(c.Request.Context(), buildReservationPreviewInput(uint(roomId), req)) + room, _, roomErr := h.roomService.GetByPublicID(c.Request.Context(), roomId.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, 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(), roomId.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)) + 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(result.Record, 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 + } + 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) + } + 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), + VenueSiteId: venueSiteID, + Slots: slots, + }) +} + +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(), roomId.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 + } + 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 } - reservation, err := h.reservationService.Submit(c.Request.Context(), buildReservationPreviewInput(uint(roomId), req)) + 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, + Expired: result.Expired, + } + 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(), uint(roomId)) + 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 +645,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 +803,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..306d21e 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,44 @@ 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 返回所有 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 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 表示记录已被其他执行者抢占,调用方应直接放弃本次执行。 +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/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.go b/internal/service/reservation_service.go index 0b41f7a..6301ec1 100644 --- a/internal/service/reservation_service.go +++ b/internal/service/reservation_service.go @@ -2,9 +2,16 @@ package service import ( "context" + "encoding/json" "fmt" + "net/url" + "strconv" + "strings" + "sync" + "time" "github.com/QSCTech/SRTP-Backend/internal/repository" + "github.com/QSCTech/SRTP-Backend/internal/zjulogin" "github.com/QSCTech/SRTP-Backend/models" ) @@ -15,66 +22,1039 @@ type ReservationVenueItem struct { } type ReservationSlotItem struct { - SlotKey string - StartTime string - EndTime string - Available bool - SpaceName *string + 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 +} + +// 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 +} + +// 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 +} + +// ReservationTemplateOutput 是场馆固定结构信息,不依赖 TYYS 实时查询窗口。 +type ReservationTemplateOutput struct { + SportType string + CampusName string + VenueName string + VenueID *uint + VenueSiteID *uint + Slots []TemplateSlot } 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 + Expired int + Errors []string } 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: 从 VenueInfo 找到 venueId / venueSiteId,同时解析 spaceId 列表供降级用。 + venueResp, err := s.tyys.VenueInfo(ctx, 0) + if err != nil { + return nil, fmt.Errorf("get venue info: %w", err) + } + + var venueIDStr, venueSiteIDStr string + var resolvedCampus, resolvedVenue string + walkVenues(venueResp.Data, func(obj map[string]any) { + sportGot := trimString(obj["sportName"]) + campusGot := trimString(obj["campusName"]) + venueGot := trimString(obj["venueName"]) + if !textMatches(sportGot, sportType) || !textMatches(campusGot, campusName) || !textMatches(venueGot, venueName) { + return + } + if venueIDStr == "" { + venueIDStr = trimString(obj["venueId"]) + venueSiteIDStr = trimString(obj["id"]) + resolvedCampus = campusGot + resolvedVenue = venueGot + } + }) + + if venueIDStr == "" || venueSiteIDStr == "" { + return nil, fmt.Errorf("venue not found for sport=%s campus=%s venue=%s", sportType, campusName, venueName) + } + + venueIDUint := parseUint(venueIDStr) + venueSiteIDUint := parseUint(venueSiteIDStr) + + // Step 2: 尝试从 TYYS 获取实时 dayInfo。 + params := url.Values{} + 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, 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 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"]) + } + + // 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: 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 + } + slots = append(slots, item) + }) + + return slots, nil +} + +// listSlotsFromTemplate 在 TYYS 预约窗口未开放时,基于 template 数据返回时间段骨架。 +// 所有 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 + } + var slots []ReservationSlotItem + 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 +} + +// 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 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 + } + 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)。 +// 依据公共体育与艺术部 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 +} + +// 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) { + 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, err + } + + out := &ReservationTemplateOutput{ + SportType: sportType, + CampusName: campusName, + VenueName: venueName, + } + 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 + } + } + + 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 +} + +// 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, + } + + 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) + 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, + }})) + _, 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) + } + 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 // 该场馆暂不可查,跳过 + } + // 同一 space 可能有多个时段,用 []PlanSlotSelection 保留全部意图 + spaceMap := make(map[uint][]PlanSlotSelection, len(group)) + for _, ps := range group { + spaceMap[ps.SpaceID] = append(spaceMap[ps.SpaceID], ps) + } + for _, live := range liveSlots { + if !live.Available { + continue + } + preferred, ok := spaceMap[live.SpaceID] + if !ok { + continue + } + // 按时间段匹配: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 +} + +// 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() + + // 清理 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 时被调用。failed 状态的计划每小时也会重试(部分场馆窗口尚未开放时的兜底)。 +func (s *ReservationService) materializeOne(ctx context.Context, plan *models.RoomReservation) error { + // 尝试从 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") + } + + 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/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) + } + } +} 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 e69c224..165312b 100644 --- a/models/room_reservation.go +++ b/models/room_reservation.go @@ -21,8 +21,14 @@ 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"` + // 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 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 "" +}