diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..b34c3c8 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +/package-lock.json + +temp diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..8c6433a --- /dev/null +++ b/server/app.js @@ -0,0 +1,57 @@ +const Koa = require('koa') +const app = new Koa() +const views = require('koa-views') +const cors = require('koa2-cors') +const json = require('koa-json') +const onerror = require('koa-onerror') +const bodyparser = require('koa-bodyparser') +const logger = require('koa-logger') + +const index = require('./routes/index') + +const { Aids: syncAids, hotelLgcDetails: syncHotelDetails, chinaHotelDetails: syncChinas } = require('./jobs/syncHeytripJobs'); +const rlog = require('./middleware/request_log'); + +// error handler +onerror(app) + +// middlewares +app.use(bodyparser({ + enableTypes:['json', 'form', 'text'] +})) +app.use(cors({ + origin: function(ctx){ return '*' } +})) +app.use(json()) +app.use(logger()) +app.use(require('koa-static')(__dirname + '/public')) + +app.use(views(__dirname + '/views', { + extension: 'ejs' +})) + +app.use(rlog); + +// logger +app.use(async (ctx, next) => { + const start = new Date() + await next() + const ms = new Date() - start + console.log(`${ctx.method} ${ctx.url} - ${ms}ms`) +}) + +// schedule jobs +// syncAids(); +// syncHotelDetails(); +// syncChinas(); + +// routes +app.use(index.routes(), index.allowedMethods()) +// app.use(routes(), allowedMethods()) + +// error-handling +app.on('error', (err, ctx) => { + console.error('server error', err, ctx) +}); + +module.exports = app diff --git a/server/bin/www b/server/bin/www new file mode 100644 index 0000000..c1b8d23 --- /dev/null +++ b/server/bin/www @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +var app = require('../app'); +var debug = require('debug')('demo:server'); +var http = require('http'); +/** + * Get port from environment and store in Express. + */ + +var port = normalizePort(process.env.PORT || '3020'); +// app.set('port', port); + +/** + * Create HTTP server. + */ + +var server = http.createServer(app.callback()); + +/** + * Listen on provided port, on all network interfaces. + */ + +server.listen(port); +server.on('error', onError); +server.on('listening', onListening); + +/** + * Normalize a port into a number, string, or false. + */ + +function normalizePort(val) { + var port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + +/** + * Event listener for HTTP server "error" event. + */ + +function onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + var bind = typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening() { + var addr = server.address(); + var bind = typeof addr === 'string' + ? 'pipe ' + addr + : 'port ' + addr.port; + debug('Listening on ' + bind); +} diff --git a/server/config/constants.js b/server/config/constants.js new file mode 100644 index 0000000..daf3e95 --- /dev/null +++ b/server/config/constants.js @@ -0,0 +1,11 @@ +const HEYTRIP_API = 'http://distapi-sandbox.heytripgo.com/Accommodation'; +const HEYTRIP_API_PROD = 'http://distapi.heytripgo.com/Accommodation'; +const LGC_MAPPED = { '1': { 'lgc': '1', locale: 'en-US' }, '2': { 'lgc': '2', locale: 'zh-CN' } }; +const DEFAULT_LGC = '1'; + +module.exports = { + HEYTRIP_API, + HEYTRIP_API_PROD, + LGC_MAPPED, + DEFAULT_LGC, +}; diff --git a/server/config/db.js b/server/config/db.js new file mode 100644 index 0000000..5a6199b --- /dev/null +++ b/server/config/db.js @@ -0,0 +1,53 @@ +const { Sequelize, DataTypes, Op } = require('sequelize'); + +/** + * + * 配置数据库 + * + */ +const testDB = new Sequelize('hotel_hub', 'admin', 'admin', { + host: 'localhost', + dialect: 'mysql', + operatorsAliases: false, + dialectOptions: { + charset: 'utf8mb4', + collate: 'utf8mb4_unicode_ci', + supportBigNumbers: true, + bigNumberStrings: true, + }, + + pool: { + max: 5, + min: 0, + acquire: 30000, + idle: 10000, + }, + timezone: '+08:00', //东八时区 +}); + +const DB = new Sequelize('hotel_hub_p', 'admin', 'admin', { + host: 'localhost', + dialect: 'mysql', + operatorsAliases: false, + dialectOptions: { + charset: 'utf8mb4', + collate: 'utf8mb4_unicode_ci', + supportBigNumbers: true, + bigNumberStrings: true, + }, + // logging: false, + + pool: { + max: 5, + min: 0, + acquire: 30000, + idle: 10000, + }, + timezone: '+08:00', //东八时区 +}); + +module.exports = { + DataTypes, Op, + // sequelize: testDB, + sequelize: DB, +}; diff --git a/server/controllers/BaseController.js b/server/controllers/BaseController.js new file mode 100644 index 0000000..c41d3e7 --- /dev/null +++ b/server/controllers/BaseController.js @@ -0,0 +1,29 @@ +class BaseController { + response = (code = 200, msg = '', data = {}, page = 1, limit = 10) => { + const res = this.getPagingData(data, page, limit); + return { + 'errcode': code, + 'msg': msg, + 'title': msg, + // 'data': data, + ...res, + }; + }; + getPagination = (page, size = 10) => { + const limit = size ? +size : 3; + const offset = page ? (page-1) * limit : 0; + + return { limit, offset }; + }; + getPagingData = (_data, page, limit) => { + const { count: totalCount, rows: data } = _data; + if ( !data || !totalCount) { + return { data: _data }; + } + const currentPage = page ? +page : 0; + const totalPages = Math.ceil(totalCount / limit); + + return { totalCount, data, totalPages, currentPage }; + }; +} +module.exports = BaseController; diff --git a/server/controllers/Heytrip.js b/server/controllers/Heytrip.js new file mode 100644 index 0000000..3e5c618 --- /dev/null +++ b/server/controllers/Heytrip.js @@ -0,0 +1,31 @@ +const BaseController = require('./BaseController'); +const heytripService = require('../services/heytripService'); + +class Heytrip extends BaseController { + + getAids = async (ctx) => { + try { + const { nextPage } = await heytripService.syncAids(); + ctx.body = this.response(0, 'get_heytrip_ids', nextPage); + } catch (error) { + // console.error(error); + ctx.body = this.response(1, error.message || 'An error occurred.'); + } + }; + + getHotelInfo = async (ctx) => { + const { data } = await heytripService.syncHotelDetails(); + ctx.body = this.response(0, 'get_hotel_info', data); + }; + + getAvailability = async (ctx) => { + try { + const data = await heytripService.getHotelAvailability(ctx.query); + ctx.body = this.response(0, 'get_hotel_availability', data); + } catch (error) { + ctx.body = this.response(1, error.message || 'An error occurred.'); + } + }; +} + +module.exports = new Heytrip(); diff --git a/server/controllers/api.js b/server/controllers/api.js new file mode 100644 index 0000000..66547e5 --- /dev/null +++ b/server/controllers/api.js @@ -0,0 +1,46 @@ +const BaseController = require('./BaseController'); +const heytripService = require('../services/heytripService'); +const { QuotedHotelsPrice } = require('../vendor/heytrip'); + +class Api extends BaseController { + + hotelSearch = async(ctx) => { + const { keyword, checkin, checkout } = ctx.query; + const page = ctx.query.page || 1; + const size = ctx.query.pagesize || 10; + try { + // if (!keyword || !checkin || !checkout) { + if (!keyword) { + ctx.throw(400, 'Missing required parameters`keyword`.'); + } + + const { limit, offset } = this.getPagination(page, size); + + const { rows, count } = await heytripService.hotelSearch(keyword, { limit, offset }); + + let quoteRes = []; + + if (checkin && checkout) { + const allIds = rows.map((item) => item.hotel_id); + quoteRes = await QuotedHotelsPrice({ + hotelIds: allIds, + nationality: 'CN', // 默认取中国报价 + CheckInDate: checkin, + CheckOutDate: checkout, + adultNum: 1, + roomCount: 1, + }); + } + + const quoteMapped = quoteRes.reduce((r, c) => ({ ...r, [c.Id]: c }), {}); + const res = rows.map((item) => ({ ...item, base_price: quoteMapped[item.hotel_id] || {} })); + + ctx.body = this.response(0, 'hotel_search', { rows: res, count }, page, size); + } catch (error) { + // console.error(error); + ctx.statusCode = error.statusCode; + ctx.body = this.response(1, error.message || 'An error occurred.'); + } + } +} +module.exports = new Api(); diff --git a/server/ecosystem.config.js b/server/ecosystem.config.js new file mode 100644 index 0000000..c75f4b5 --- /dev/null +++ b/server/ecosystem.config.js @@ -0,0 +1,41 @@ +const fs = require('fs'); + +var date = new Date(); +// var dat= date.getDate()+"-"+(date.getMonth()+1)+"-"+date.getFullYear(); +var dat= `${date.getFullYear()}-${(date.getMonth()+1)}-${date.getDate()}`; + +var err_log = "./pm2/logs/err.log"; +var out_log = "./pm2/logs/out.log"; + +fs.mkdirSync(`./pm2/logs/${dat}`, { recursive: true }); + +err_log = './pm2/logs/' + dat + '/error.log'; +out_log = './pm2/logs/' + dat + '/output.log'; +combined_log = './pm2/logs/' + dat + '/combined.log'; + +module.exports = { + apps: [ + { + name: 'hotelhub', + script: 'bin/www', + max_memory_restart: "500M", + merge_logs: true, + max_restarts: 20, + error_file: err_log, + out_file: out_log, + // instances: 1, + watch: false + } + ] +} +// export const apps = [{ +// name: 'hotelhub', +// script: 'bin/www', +// max_memory_restart: "500M", +// merge_logs: true, +// max_restarts: 20, +// error_file: err_log, +// out_file: out_log, +// // instances: 1, +// watch: '.' +// }]; diff --git a/server/helper/heytripDataHelper.js b/server/helper/heytripDataHelper.js new file mode 100644 index 0000000..9b3d395 --- /dev/null +++ b/server/helper/heytripDataHelper.js @@ -0,0 +1,347 @@ +const { objectMapper: _objectMapper, isNotEmpty } = require('../utils/commons'); +const { DEFAULT_LGC, LGC_MAPPED } = require('../config/constants'); + +const objectMapper = (input, keyMap) => _objectMapper(input || {}, keyMap, false); + +const infoDataMapper = (row) => { + const item = objectMapper(row, { + HotelId: 'hotel_id', + HotelName: 'hotel_name', + CountryCode: 'country_code', + CityId: 'city_id', + Address: 'address', + Phone: 'phone', + Latitude: 'latitude', + Longitude: 'longitude', + Rating: 'rating', + HotelType: 'hotel_type', + Brand: 'brand', + PostalCode: 'postal_code', + HeroImg: 'hero_img', + SupplierType: 'supplier_type', + }); + const review = objectMapper(row.Review, { + Score: 'review_score', + ReviewCount: 'review_count', + Desc: 'review_desc', + }); + const location = objectMapper(row.Location, { + PoiScore: 'location_poi_score', + AirportScore: 'location_airport_score', + TransportationScore: 'location_transportation_score', + }); + return {...item, ...review, ...location}; +} + +const info2DataMapper = (row, lgcObj) => { + const item = objectMapper(row, { + HotelId: 'hotel_id', + LocaleName: 'hi2_hotel_name', + Description: 'hi2_description', + AddressLocale: 'hi2_address', + }); + const instruction = objectMapper(row.Instruction, { + Desc: 'h2_instruction_desc', + Special: 'h2_instruction_special', + FeesDesc: 'h2_instruction_fees_desc', + }); + const location = objectMapper((row.Location?.WalkablePlaces), { + Title: 'h2_location_walkable_places_title', + Desc: 'h2_location_walkable_places_desc', + }); + const highlight = objectMapper(row.Highlight, { + LocationHighlightDesc: 'h2_location_highlight_desc', + }); + const review = objectMapper(row.Review, { + Desc: 'h2_review_desc', + }); + return { ...item, ...instruction, ...location, ...highlight, ...lgcObj, ...review }; +}; + +const roomsMapper = (row, lgcObj) => { + const rooms = (row.Rooms || []).map((rowRoom) => ({ + hotel_id: row.HotelId, + ...lgcObj, + ...(objectMapper(rowRoom, { + RoomId: 'room_id', + RoomName: 'room_name', + LocaleName: 'locale_name', + BedTypeDesc: 'bed_type_desc', + Area: 'area', + Views: 'views', + Window: 'window', + Floor: 'floor', + WirelessWideband: 'wireless_wideband', + WiredBroadband: 'wired_broadband', + Smoking: 'smoking', + BathRoomType: 'bathroom_type', + // Images: 'images', + })), + max_occupancy: rowRoom.MaxOccupancy ? JSON.stringify(rowRoom.MaxOccupancy) : null, + bedrooms: rowRoom.BedRooms ? JSON.stringify(rowRoom.BedRooms) : null, + })); + return rooms; +}; +const imagesDaraMapper = (row, lgcObj) => { + const images = (row.Rooms || []).reduce( + (r, rowRoom) => + r.concat( + (rowRoom.Images || []).map((imgItem) => ({ + hotel_id: row.HotelId, + ...lgcObj, + info_source: 'rooms', + info_source_id: rowRoom.RoomId, + type: null, + url: imgItem.Url, + caption: null, + category: imgItem.Category, + })) + ), + [] + ); + const urls = (row.Urls || []).reduce( + (r, rowUrl) => + r.concat( + rowUrl.Urls.map((imgItem) => ({ + hotel_id: row.HotelId, + ...lgcObj, + info_source: 'hotelinfo', + info_source_id: row.HotelId, + type: imgItem.Type, + url: imgItem.Url, + caption: rowUrl.Caption, + category: rowUrl.Category, + })) + ), + [] + ); + return [].concat(images, urls); +}; +const mainhighlightsMapper = (row, lgcObj) => { + return (row.Highlight?.MainHighlights || []).map((mhl) => ({ + hotel_id: row.HotelId, // hotel_id: + ...lgcObj, + type: 'MainHighlights', // type: + category: mhl.Category, // category: + category_name: '', // category_name: + id: null, // id: + name: mhl.Name, // name: + symbol: null, // symbol: + tooltip: mhl.Tooltip, // tooltip: + })); +}; +const facilityCMapper = (row, lgcObj) => { + return (row.Facility?.Categories || []).reduce( + (r, fc) => + r.concat( + fc.Items.map((fcItem) => ({ + hotel_id: row.HotelId, // hotel_id: + ...lgcObj, + type: 'Facility', // type: + category: fc.Category, // category: + category_name: fc.Name, // category_name: + id: fcItem.Id, // id: + name: fcItem.Name, // name: + symbol: fcItem.Symbol, // symbol: + tooltip: null, // tooltip: + })) + ), + [] + ); +}; +const facilityHLMapper = (row, lgcObj) => + (row.Facility?.Highlights || []).map((fl) => ({ + hotel_id: row.HotelId, // hotel_id: + ...lgcObj, + type: 'Highlights', // type: + category: '', // category: + category_name: '', // category_name: + id: fl.Id, // id: + name: fl.Name, // name: + symbol: fl.Symbol, // symbol: + tooltip: null, // tooltip: + })); + +const informationsMapper = (row, lgcObj) => + (row.Informations || []).reduce( + (r, fc) => + r.concat( + fc.Items.map((fcItem) => ({ + hotel_id: row.HotelId, // hotel_id: + ...lgcObj, + category: fc.Category, // category: + category_name: fc.CategoryName, // category_name: + id: fcItem.Id, // id: + name: fcItem.Name, // name: + value: fcItem.Value, // value: + })) + ), + [] + ); +const locationPlacesMapper = (sourceName, hotelId, places, lgcObj) => + places.map((item) => ({ + hotel_id: hotelId, // hotel_id: + ...lgcObj, + location_type: sourceName, // location_type: + category: null, // category: + category_name: null, // category_name: + id: item.Id || null, // id: + name: item.Name, // name: + distance: item.Distance, // distance: + latitude: item.Latitude || null, // latitude: + longitude: item.Longitude || null, // longitude: + type_name: item.TypeName || null, // type_name: + type_id: item.TypeId || null, // type_id: + min_distance: null, // min_distance: + })); +const scoresMapper = (sourceName, hotelId, scores, lgcObj) => scores.map((item) => ({ + hotel_id: hotelId, // hotel_id: + ...lgcObj, + type: sourceName, // type: + name: sourceName==='ScoreDetails' ? item.Name : null, // name: + category: item.Category || null, // category: + score: item.Score || null, // score: + city_average: item.CityAverage || null, // city_average: + mention_name: sourceName==='PositiveMentions' ? item.Name : null, // mention_name: + mention_count: sourceName==='PositiveMentions' ? item.Count : null, // mention_count: +})); + +/** + * 解析Details数据 + * todo: BedRoom MaxOccupancy + */ +const resolveDetails = (res, lgcObj) => { + return res.reduce( + (rd, c) => { + rd.info.push(infoDataMapper(c)); + rd.info2.push(info2DataMapper(c, lgcObj)); + rd.rooms = rd.rooms.concat(roomsMapper(c, lgcObj)); + + rd.images = rd.images.concat(imagesDaraMapper(c, lgcObj)); + // rd.images = rd.images.concat(urls); + + // rd.test = rd.test.concat([c.Highlight.LocationHighlightDesc]); // test: + + const MainHighlights = mainhighlightsMapper(c, lgcObj); + const facilityC = facilityCMapper(c, lgcObj); + const facilityHL = facilityHLMapper(c, lgcObj); + rd.facility = rd.facility.concat(MainHighlights, facilityC, facilityHL); + + const infos = informationsMapper(c, lgcObj); + rd.infos = rd.infos.concat(infos); + + const reviewScores = scoresMapper('ScoreDetails', c.HotelId, c.Review?.ScoreDetails || [], lgcObj); + const mentions = scoresMapper('PositiveMentions', c.HotelId, c.Review?.PositiveMentions || [], lgcObj); + rd.reviews = rd.reviews.concat(reviewScores, mentions); + + const reviewSummaries = (c.Review?.Summaries || []).map((item) => ({ + hotel_id: c.HotelId, // hotel_id: + ...lgcObj, + country: item.Country, // country: + reviewer: item.Reviewer, // reviewer: + review_rating: item.ReviewRating, // review_rating: + desc: item.Desc, // desc: + review_date: item.ReviewDate, // review_date: + })); + rd.review_summaries = rd.review_summaries.concat(reviewSummaries); + + const TopPlaces = locationPlacesMapper('TopPlaces', c.HotelId, c.Location?.TopPlaces || [], lgcObj); + const Shops = locationPlacesMapper('Shops', c.HotelId, c.Location?.Shops || [], lgcObj); + const Places = locationPlacesMapper('Places', c.HotelId, c.Location?.Places || [], lgcObj); + const NearbyCategories = (c.Location?.NearbyCategories || []).reduce( + (r, nc) => + r.concat( + locationPlacesMapper('NearbyCategories', c.HotelId, nc.Places || [], lgcObj).map((item) => ({ + ...item, + category: nc.Category, + category_name: nc.Name, + min_distance: nc.MinDistance, + })) + ), + [] + ); + const WalkablePlaces = (c.Location?.WalkablePlaces?.Categories || []).reduce( + (r, nc) => + r.concat( + locationPlacesMapper('WalkablePlaces', c.HotelId, nc.Places || [], lgcObj).map((item) => ({ + ...item, + category: nc.Name, + category_name: nc.Name, + })) + ), + [] + ); + rd.locations = rd.locations.concat(TopPlaces, Shops, Places, NearbyCategories, WalkablePlaces); + + return rd; + }, + { info: [], info2: [], rooms: [], images: [], facility: [], infos: [], reviews: [], review_summaries: [], locations: [], test: [] } + ); +} + +/** + * 解析Availability数据 + */ + +/** + * 餐食类型 1明确数量(早中晚餐食看Breakfast Lunch Dinner 数量);2半包;3全包;4午/晚二选一;5早+ 午/晚二选一; + */ +const MealTypeMapped = { + '1': { value: 1, label: ({ Breakfast, Lunch, Dinner }) => `${Breakfast}份早餐` }, + '2': { value: 2, label: ({ Breakfast, Lunch, Dinner }) => '半包' }, + '3': { value: 3, label: ({ Breakfast, Lunch, Dinner }) => '全包' }, + '4': { value: 4, label: ({ Breakfast, Lunch, Dinner }) => '午/晚二选一' }, + '5': { value: 5, label: ({ Breakfast, Lunch, Dinner }) => '早+ 午/晚二选一' }, +}; + +/** + * 付款方式 1现付 2预付 + */ +const PayTypeMapped = { + '1': { value: 1, label: () => '现付' }, + '2': { value: 2, label: () => '预付' }, +}; +/** + * 开票方式:0 Unknown 、1 提供方开票 、2 酒店开票、3 供应商开票 + */ +const InvoiceTypeMapped = { + '0': { value: 0, label: () => '未知' }, + '1': { value: 1, label: () => '提供方开票' }, + '2': { value: 2, label: () => '酒店开票' }, + '3': { value: 3, label: () => '供应商开票' }, +}; +/** + * 取消手续费类型:0 未知 1扣首日 2扣全额 3按价格多少百分比扣 4免费取消 5扣几晚 6扣多少钱 + */ +const cancelDeductTypeMapped = { + '0': { value: 0, label: () => '未知' }, + '1': { value: 1, label: () => '扣首日' }, + '2': { value: 2, label: () => '扣全额' }, + '3': { value: 3, label: (value) => `扣款${value * 100}%` }, + '4': { value: 4, label: () => '免费取消' }, + '5': { value: 5, label: (value) => `扣${value}晚` }, + '6': { value: 6, label: (value) => `扣款${value}` }, +}; +/** + * 解析报价数据 + */ +const resolveRatePlans = (plans) => { + const res = plans.map((room) => ({ + ...room, + RatePlans: room.RatePlans.map((rp) => { + const PriceUnit = rp.Dailys[0].Price; + const CancelableText = rp.Cancelable === true ? '限时取消' : '不可取消'; + const CancelRulesMapped = (rp.CancelRules || []).map((citem) => { + let OutputText = isNotEmpty(citem.StartTime) ? `北京时间: ${citem.StartTime}至${citem.EndTime}, ` : ''; + OutputText += cancelDeductTypeMapped[citem.DeductType].label(citem.DeductValue); + OutputText += citem.Desc; + return OutputText; + }); // 限时取消 + rp.Cancelable === true ? CancelRulesMapped.push('其他时间不可取消') : false; + return { ...rp, PriceUnit, PriceTotal: rp.Price, CancelRulesText: CancelRulesMapped, CancelableText }; + }), + })); + return res; +}; + +module.exports = { resolveDetails, resolveRatePlans }; diff --git a/server/jobs/syncHeytripJobs.js b/server/jobs/syncHeytripJobs.js new file mode 100644 index 0000000..a5aef67 --- /dev/null +++ b/server/jobs/syncHeytripJobs.js @@ -0,0 +1,58 @@ +const { scheduleJob } = require('node-schedule'); +const heytripService = require('../services/heytripService'); +const { LGC_MAPPED } = require('../config/constants'); + +const Aids = () => { + const job = scheduleJob('*/2 * * * * *', async function () { + console.log('syncing heytrip, get available accommodation ids.'); + + const res = await heytripService.syncAids(); + if (res.nextPage !== true) { + job.cancel(); + } + }); +}; + +const hotelLgcDetails = () => { + const job2 = scheduleJob('*/4 * * * * *', async function () { + console.log('-------------------------syncing heytrip, get accommodation details.-------------------------'); + + const isRunning = job2.pendingInvocations[0]?.job?.running == 1; + if (!isRunning) { + // const res = await heytripService.syncHotelDetails(); + // const res = await heytripService.syncInitHotelLgcDetailsAction(LGC_MAPPED['1']); + const res = await heytripService.newHotelsLgc('1'); + + job2.cancel(); // debug: 0 + if (res.next !== true) { + job2.cancel(); + console.log('job completed! canceled job!'); + } + } else { + console.log('pre job running! cancelNext'); + job2.cancelNext(); + } + }); +}; + +const chinaHotelDetails = () => { + const job3 = scheduleJob('*/4 * * * * *', async function () { + console.log('syncing heytrip, get china accommodation details.'); + const isRunning = job3.pendingInvocations[0]?.job?.running == 1; + if (!isRunning) { + const res = await heytripService.chinaHotelsLgc2('2'); + if (res.next !== true) { + job3.cancel(); + console.log('job completed! canceled job!'); + // job3.reschedule('0 0 0 * * *'); + } + } else { + console.log('pre job running! cancelNext'); + job2.cancelNext(); + } + }); +}; + +module.exports = { + Aids, hotelLgcDetails, chinaHotelDetails +} diff --git a/server/middleware/request_log.js b/server/middleware/request_log.js new file mode 100644 index 0000000..b080874 --- /dev/null +++ b/server/middleware/request_log.js @@ -0,0 +1,15 @@ +const requestLogsService = require('./../services/requestLogsService'); +const rlog = async (ctx, next) => { + try { + await next(); + } catch (err) { + } finally { + await requestLogsService.create({ + method: ctx.method, + path: ctx.method === 'GET' ? ctx.path : ctx.url, + request_data: ctx.method === 'GET' ? JSON.stringify(ctx.query) : JSON.stringify(ctx.request.body), + ip: ctx.ip, + }); + } +}; +module.exports = rlog; diff --git a/server/models/cache_availability.js b/server/models/cache_availability.js new file mode 100644 index 0000000..96e5452 --- /dev/null +++ b/server/models/cache_availability.js @@ -0,0 +1,36 @@ +import Sequelize from 'sequelize'; +export default function(sequelize, DataTypes) { + return sequelize.define('cache_availability', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + } + }, { + sequelize, + tableName: 'cache_availability', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "cache_availability_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/models/city.js b/server/models/city.js new file mode 100644 index 0000000..10262f8 --- /dev/null +++ b/server/models/city.js @@ -0,0 +1,56 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('city', { + sn: { + autoIncrement: true, + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + }, + id: { + type: DataTypes.STRING(10), + allowNull: true + }, + name: { + type: DataTypes.STRING(100), + allowNull: true + }, + latitude: { + type: DataTypes.DOUBLE, + allowNull: true + }, + longitude: { + type: DataTypes.DOUBLE, + allowNull: true + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + } + }, { + sequelize, + tableName: 'city', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "city_id_IDX", + using: "BTREE", + fields: [ + { name: "id" }, + ] + }, + ] + }); +}; diff --git a/server/models/country.js b/server/models/country.js new file mode 100644 index 0000000..f92018f --- /dev/null +++ b/server/models/country.js @@ -0,0 +1,56 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('country', { + sn: { + autoIncrement: true, + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + }, + id: { + type: DataTypes.STRING(10), + allowNull: true + }, + name: { + type: DataTypes.STRING(100), + allowNull: true + }, + latitude: { + type: DataTypes.DOUBLE, + allowNull: true + }, + longitude: { + type: DataTypes.DOUBLE, + allowNull: true + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + } + }, { + sequelize, + tableName: 'country', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "countries_id_IDX", + using: "BTREE", + fields: [ + { name: "id" }, + ] + }, + ] + }); +}; diff --git a/server/models/facility.js b/server/models/facility.js new file mode 100644 index 0000000..72e8f51 --- /dev/null +++ b/server/models/facility.js @@ -0,0 +1,72 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('facility', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + }, + type: { + type: DataTypes.STRING(100), + allowNull: true + }, + category: { + type: DataTypes.STRING(100), + allowNull: true + }, + category_name: { + type: DataTypes.STRING(100), + allowNull: true + }, + id: { + type: DataTypes.STRING(100), + allowNull: true + }, + name: { + type: DataTypes.STRING(100), + allowNull: true + }, + symbol: { + type: DataTypes.STRING(100), + allowNull: true + }, + tooltip: { + type: DataTypes.STRING(1000), + allowNull: true + } + }, { + sequelize, + tableName: 'facility', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "facility_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/models/heytrip_ids.js b/server/models/heytrip_ids.js new file mode 100644 index 0000000..6563cee --- /dev/null +++ b/server/models/heytrip_ids.js @@ -0,0 +1,44 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('heytrip_ids', { + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + last_modify_time: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP') + }, + update_flag: { + type: DataTypes.TINYINT, + allowNull: true, + defaultValue: 1, + comment: "状态,0更新完成,1待更新" + }, + priority: { + type: DataTypes.TINYINT, + allowNull: true, + comment: "优先级:low: 10 , normal: 0 , medium: -5 , high: -10 , critical: -15" + }, + page_index: { + type: DataTypes.INTEGER, + allowNull: true + } + }, { + sequelize, + tableName: 'heytrip_ids', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/models/hotelinfo.js b/server/models/hotelinfo.js new file mode 100644 index 0000000..a4d664c --- /dev/null +++ b/server/models/hotelinfo.js @@ -0,0 +1,127 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('hotelinfo', { + hi_sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false, + unique: "hotelinfo_unique" + }, + hotel_name: { + type: DataTypes.STRING(100), + allowNull: true + }, + country_code: { + type: DataTypes.STRING(10), + allowNull: true + }, + city_id: { + type: DataTypes.STRING(10), + allowNull: true + }, + address: { + type: DataTypes.STRING(1000), + allowNull: true + }, + phone: { + type: DataTypes.STRING(200), + allowNull: true + }, + latitude: { + type: DataTypes.STRING(100), + allowNull: true + }, + longitude: { + type: DataTypes.STRING(100), + allowNull: true + }, + rating: { + type: DataTypes.DECIMAL(4,1), + allowNull: true + }, + hotel_type: { + type: DataTypes.STRING(100), + allowNull: true + }, + brand: { + type: DataTypes.STRING(100), + allowNull: true + }, + postal_code: { + type: DataTypes.STRING(100), + allowNull: true + }, + hero_img: { + type: DataTypes.STRING(1000), + allowNull: true + }, + supplier_type: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "酒店供应类型:SF(自签单体) null(普通) 默认是null" + }, + review_score: { + type: DataTypes.FLOAT, + allowNull: true + }, + review_count: { + type: DataTypes.INTEGER, + allowNull: true + }, + review_desc: { + type: DataTypes.STRING(1000), + allowNull: true + }, + location_poi_score: { + type: DataTypes.FLOAT, + allowNull: true + }, + location_airport_score: { + type: DataTypes.FLOAT, + allowNull: true + }, + location_transportation_score: { + type: DataTypes.FLOAT, + allowNull: true + }, + last_modify_time: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP') + } + }, { + sequelize, + tableName: 'hotelinfo', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "hi_sn" }, + ] + }, + { + name: "hotelinfo_unique", + unique: true, + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + { + name: "hotelinfo_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/models/hotelinfo2.js b/server/models/hotelinfo2.js new file mode 100644 index 0000000..18fc004 --- /dev/null +++ b/server/models/hotelinfo2.js @@ -0,0 +1,100 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('hotelinfo2', { + hi2_sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + }, + hi2_hotel_name: { + type: DataTypes.STRING(100), + allowNull: true + }, + hi2_address: { + type: DataTypes.STRING(1000), + allowNull: true + }, + hi2_description: { + type: DataTypes.TEXT, + allowNull: true + }, + h2_instruction_desc: { + type: DataTypes.TEXT, + allowNull: true + }, + h2_instruction_special: { + type: DataTypes.TEXT, + allowNull: true + }, + h2_instruction_fees_desc: { + type: DataTypes.TEXT, + allowNull: true + }, + h2_location_walkable_places_title: { + type: DataTypes.STRING(1000), + allowNull: true + }, + h2_location_walkable_places_desc: { + type: DataTypes.TEXT, + allowNull: true + }, + h2_location_highlight_desc: { + type: DataTypes.STRING(1000), + allowNull: true + }, + h2_review_desc: { + type: DataTypes.STRING(1000), + allowNull: true + } + }, { + sequelize, + tableName: 'hotelinfo2', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "hi2_sn" }, + ] + }, + { + name: "hotelinfo2_unique", + unique: true, + using: "BTREE", + fields: [ + { name: "hotel_id" }, + { name: "lgc" }, + ] + }, + { + name: "hotelinfo2_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + { + name: "hotelinfo2_lgc_IDX", + using: "BTREE", + fields: [ + { name: "lgc" }, + ] + }, + ] + }); +}; diff --git a/server/models/images.js b/server/models/images.js new file mode 100644 index 0000000..5d3bea6 --- /dev/null +++ b/server/models/images.js @@ -0,0 +1,83 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('images', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + }, + info_source: { + type: DataTypes.STRING(100), + allowNull: true + }, + info_source_id: { + type: DataTypes.STRING(100), + allowNull: true + }, + type: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "Mid小图; Max大图;" + }, + url: { + type: DataTypes.STRING(1000), + allowNull: true + }, + caption: { + type: DataTypes.STRING(1000), + allowNull: true + }, + category: { + type: DataTypes.STRING(100), + allowNull: true + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + } + }, { + sequelize, + tableName: 'images', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "image_urls_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + { + name: "image_urls_source_id_IDX", + using: "BTREE", + fields: [ + { name: "info_source_id" }, + ] + }, + { + name: "images_lgc_IDX", + using: "BTREE", + fields: [ + { name: "lgc" }, + ] + }, + ] + }); +}; diff --git a/server/models/informations.js b/server/models/informations.js new file mode 100644 index 0000000..601281b --- /dev/null +++ b/server/models/informations.js @@ -0,0 +1,64 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('informations', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + }, + category: { + type: DataTypes.STRING(100), + allowNull: true + }, + category_name: { + type: DataTypes.STRING(100), + allowNull: true + }, + id: { + type: DataTypes.STRING(100), + allowNull: true + }, + name: { + type: DataTypes.STRING(100), + allowNull: true + }, + value: { + type: DataTypes.STRING(1000), + allowNull: true + } + }, { + sequelize, + tableName: 'informations', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "facility_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/models/init-models.js b/server/models/init-models.js new file mode 100644 index 0000000..9fac9d5 --- /dev/null +++ b/server/models/init-models.js @@ -0,0 +1,49 @@ +var DataTypes = require("sequelize").DataTypes; +var _city = require("./city"); +var _country = require("./country"); +var _facility = require("./facility"); +var _heytrip_ids = require("./heytrip_ids"); +var _hotelinfo = require("./hotelinfo"); +var _hotelinfo2 = require("./hotelinfo2"); +var _images = require("./images"); +var _informations = require("./informations"); +var _locations = require("./locations"); +var _request_logs = require("./request_logs"); +var _reviews = require("./reviews"); +var _reviews_summaries = require("./reviews_summaries"); +var _rooms = require("./rooms"); + +function initModels(sequelize) { + var cityModel = _city(sequelize, DataTypes); + var countryModel = _country(sequelize, DataTypes); + var facilityModel = _facility(sequelize, DataTypes); + var heytripIdsModel = _heytrip_ids(sequelize, DataTypes); + var hotelinfoModel = _hotelinfo(sequelize, DataTypes); + var hotelinfo2Model = _hotelinfo2(sequelize, DataTypes); + var imagesModel = _images(sequelize, DataTypes); + var informationsModel = _informations(sequelize, DataTypes); + var locationsModel = _locations(sequelize, DataTypes); + var requestLogsModel = _request_logs(sequelize, DataTypes); + var reviewsModel = _reviews(sequelize, DataTypes); + var reviewsSummariesModel = _reviews_summaries(sequelize, DataTypes); + var roomsModel = _rooms(sequelize, DataTypes); + + return { + cityModel, + countryModel, + facilityModel, + heytripIdsModel, + hotelinfoModel, + hotelinfo2Model, + imagesModel, + informationsModel, + locationsModel, + requestLogsModel, + reviewsModel, + reviewsSummariesModel, + roomsModel, + }; +} +module.exports = initModels; +module.exports.initModels = initModels; +module.exports.default = initModels; diff --git a/server/models/locations.js b/server/models/locations.js new file mode 100644 index 0000000..95d2bed --- /dev/null +++ b/server/models/locations.js @@ -0,0 +1,90 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('locations', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + }, + location_type: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "TopPlaces; Shops; Places; NearbyCategories;" + }, + category: { + type: DataTypes.STRING(100), + allowNull: true + }, + category_name: { + type: DataTypes.STRING(100), + allowNull: true + }, + id: { + type: DataTypes.STRING(100), + allowNull: true + }, + name: { + type: DataTypes.STRING(1000), + allowNull: true + }, + distance: { + type: DataTypes.FLOAT, + allowNull: true, + comment: "距离(km)" + }, + latitude: { + type: DataTypes.DOUBLE, + allowNull: true + }, + longitude: { + type: DataTypes.DOUBLE, + allowNull: true + }, + type_name: { + type: DataTypes.STRING(100), + allowNull: true + }, + type_id: { + type: DataTypes.STRING(100), + allowNull: true + }, + min_distance: { + type: DataTypes.FLOAT, + allowNull: true + } + }, { + sequelize, + tableName: 'locations', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "locations_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/models/request_logs.js b/server/models/request_logs.js new file mode 100644 index 0000000..9635747 --- /dev/null +++ b/server/models/request_logs.js @@ -0,0 +1,54 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('request_logs', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + action: { + type: DataTypes.STRING(100), + allowNull: true + }, + method: { + type: DataTypes.STRING(15), + allowNull: true + }, + path: { + type: DataTypes.STRING(1000), + allowNull: true + }, + request_data: { + type: DataTypes.TEXT, + allowNull: true + }, + snapshots: { + type: DataTypes.TEXT, + allowNull: true + }, + ip: { + type: DataTypes.STRING(200), + allowNull: true + }, + createtime: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP') + } + }, { + sequelize, + tableName: 'request_logs', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + ] + }); +}; diff --git a/server/models/reviews.js b/server/models/reviews.js new file mode 100644 index 0000000..45c3df5 --- /dev/null +++ b/server/models/reviews.js @@ -0,0 +1,74 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('reviews', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + }, + type: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "ScoreDetails; Summaries; PositiveMentions" + }, + name: { + type: DataTypes.STRING(100), + allowNull: true + }, + category: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "种类 cleanliness(环境和清洁度) facilities(设施) location(位置) roomComfort(客房舒适度) staffPerformance(服务)valueForMoney(性价比)" + }, + score: { + type: DataTypes.FLOAT, + allowNull: true + }, + city_average: { + type: DataTypes.FLOAT, + allowNull: true + }, + mention_name: { + type: DataTypes.STRING(100), + allowNull: true + }, + mention_count: { + type: DataTypes.INTEGER, + allowNull: true + } + }, { + sequelize, + tableName: 'reviews', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "reviews_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/models/reviews_summaries.js b/server/models/reviews_summaries.js new file mode 100644 index 0000000..50ae77b --- /dev/null +++ b/server/models/reviews_summaries.js @@ -0,0 +1,64 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('reviews_summaries', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + }, + country: { + type: DataTypes.STRING(100), + allowNull: true + }, + reviewer: { + type: DataTypes.STRING(100), + allowNull: true + }, + review_rating: { + type: DataTypes.FLOAT, + allowNull: true + }, + desc: { + type: DataTypes.TEXT, + allowNull: true + }, + review_date: { + type: DataTypes.DATE, + allowNull: true + } + }, { + sequelize, + tableName: 'reviews_summaries', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "reviews_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/models/rooms.js b/server/models/rooms.js new file mode 100644 index 0000000..2f80c2f --- /dev/null +++ b/server/models/rooms.js @@ -0,0 +1,112 @@ +const Sequelize = require('sequelize'); +module.exports = function(sequelize, DataTypes) { + return sequelize.define('rooms', { + sn: { + autoIncrement: true, + type: DataTypes.BIGINT, + allowNull: false, + primaryKey: true + }, + hotel_id: { + type: DataTypes.BIGINT, + allowNull: false + }, + lgc: { + type: DataTypes.TINYINT, + allowNull: false + }, + locale: { + type: DataTypes.STRING(100), + allowNull: true + }, + room_id: { + type: DataTypes.STRING(100), + allowNull: false + }, + room_name: { + type: DataTypes.STRING(500), + allowNull: true + }, + locale_name: { + type: DataTypes.STRING(500), + allowNull: true + }, + bed_type_desc: { + type: DataTypes.STRING(1000), + allowNull: true + }, + area: { + type: DataTypes.STRING(100), + allowNull: true + }, + views: { + type: DataTypes.STRING(100), + allowNull: true + }, + window: { + type: DataTypes.INTEGER, + allowNull: true, + comment: "窗户信息 0未知 1有窗 2无窗 3部分有窗 4内窗 5封闭窗 6部分内窗" + }, + floor: { + type: DataTypes.STRING(100), + allowNull: true + }, + wireless_wideband: { + type: DataTypes.INTEGER, + allowNull: true, + comment: "无线网络信息 0未知 1无 2免费 3收费 4部分收费 5部分有且收费 6部分有且免费" + }, + wired_broadband: { + type: DataTypes.INTEGER, + allowNull: true, + comment: "有线网络信息 0未知 1无 2免费 3收费 4部分收费 5部分有且收费 6部分有且免费" + }, + smoking: { + type: DataTypes.INTEGER, + allowNull: true, + comment: "吸烟信息 0未知 1禁烟 2部分禁烟 3可吸烟" + }, + bathroom_type: { + type: DataTypes.INTEGER, + allowNull: true, + comment: "卫浴信息 0未知 1独立卫浴 2公共卫浴" + }, + max_occupancy: { + type: DataTypes.JSON, + allowNull: true + }, + bedrooms: { + type: DataTypes.JSON, + allowNull: true + } + }, { + sequelize, + tableName: 'rooms', + timestamps: false, + indexes: [ + { + name: "PRIMARY", + unique: true, + using: "BTREE", + fields: [ + { name: "sn" }, + ] + }, + { + name: "rooms_room_id_IDX", + using: "BTREE", + fields: [ + { name: "room_id" }, + ] + }, + { + name: "rooms_hotel_id_IDX", + using: "BTREE", + fields: [ + { name: "hotel_id" }, + ] + }, + ] + }); +}; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..1b050e2 --- /dev/null +++ b/server/package.json @@ -0,0 +1,32 @@ +{ + "name": "HotelHub", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "node bin/www", + "dev": "nodemon bin/www", + "prd": "pm2 start bin/www", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "axios": "^1.7.3", + "debug": "^4.1.1", + "ejs": "~2.3.3", + "koa": "^2.7.0", + "koa-bodyparser": "^4.2.1", + "koa-convert": "^1.2.0", + "koa-json": "^2.0.2", + "koa-logger": "^3.2.0", + "koa-onerror": "^4.1.0", + "koa-router": "^7.4.0", + "koa-static": "^5.0.0", + "koa-views": "^6.2.0", + "koa2-cors": "^2.0.6", + "mysql2": "^3.11.0", + "node-schedule": "^2.1.1", + "sequelize": "^6.37.3" + }, + "devDependencies": { + "nodemon": "^1.19.1" + } +} diff --git a/server/public/stylesheets/style.css b/server/public/stylesheets/style.css new file mode 100644 index 0000000..9453385 --- /dev/null +++ b/server/public/stylesheets/style.css @@ -0,0 +1,8 @@ +body { + padding: 50px; + font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; +} + +a { + color: #00B7FF; +} diff --git a/server/routes/index.js b/server/routes/index.js new file mode 100644 index 0000000..5a33bb5 --- /dev/null +++ b/server/routes/index.js @@ -0,0 +1,13 @@ +const router = require('koa-router')(); +const Heytrip = require('./../controllers/Heytrip'); +const Api = require('./../controllers/Api'); + +// router.get('/get_heytrip_ids', Heytrip.getAids); + +// router.get('/get_hotel_info', Heytrip.getHotelInfo); + +router.get('/search_hotel', Api.hotelSearch); + +router.get('/availability', Heytrip.getAvailability); + +module.exports = router; diff --git a/server/routes/users.js b/server/routes/users.js new file mode 100644 index 0000000..78a63b2 --- /dev/null +++ b/server/routes/users.js @@ -0,0 +1,14 @@ +import Router from 'koa-router'; +const router = new Router(); + +router.prefix('/users') + +router.get('/', function (ctx, next) { + ctx.body = 'this is a users response!' +}) + +router.get('/bar', function (ctx, next) { + ctx.body = 'this is a users/bar response' +}) + +export default router diff --git a/server/services/heytripService.js b/server/services/heytripService.js new file mode 100644 index 0000000..d707e25 --- /dev/null +++ b/server/services/heytripService.js @@ -0,0 +1,307 @@ +const db = require('../config/db'); +const initModels = require('./../models/init-models'); +const { AvailableAccommodationIds, AccommodationsDetails, Availability } = require('../vendor/heytrip'); +const { isEmpty } = require('../utils/commons'); +const { resolveDetails, resolveRatePlans } = require('../helper/heytripDataHelper'); +const { DEFAULT_LGC, LGC_MAPPED } = require('../config/constants'); + +const { sequelize: Sequelize, Op } = db; + +const models = initModels(Sequelize); + +const Country = models.countryModel; +const City = models.cityModel; + +const HeytripIds = models.heytripIdsModel; +const Hotelinfo = models.hotelinfoModel; +const Hotelinfo2 = models.hotelinfo2Model; +const Rooms = models.roomsModel; +const Facility = models.facilityModel; +const Images = models.imagesModel; +const Informations = models.informationsModel; +const Reviews = models.reviewsModel; +const ReviewsSummaries = models.reviewsSummariesModel; +const Locations = models.locationsModel; +// const CacheAvailability = models.cacheAvailabilityModel; + +// HeytripIds.hasMany(Hotelinfo, { +// foreignKey: 'hotel_id', +// onDelete: 'NO ACTION', +// onUpdate: 'NO ACTION', +// }); +// Hotelinfo.belongsTo(HeytripIds, { as: 'aid', foreignKey: 'hotel_id', onDelete: 'NO ACTION', onUpdate: 'NO ACTION' }); +// Hotelinfo.hasMany(Rooms, { sourceKey: 'hotel_id', foreignKey: 'hotel_id', onDelete: 'NO ACTION', onUpdate: 'NO ACTION' }); +// Rooms.belongsTo(Hotelinfo, { targetKey: 'hotel_id', foreignKey: 'hotel_id', onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }); +Hotelinfo.hasMany(Hotelinfo2, { as: 'lgc_info', sourceKey: 'hotel_id', foreignKey: 'hotel_id', onDelete: 'NO ACTION', onUpdate: 'NO ACTION' }); +Hotelinfo.hasMany(City, { as: 'city', sourceKey: 'city_id', foreignKey: 'id', onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }); // 多语种, 所以实际是 hasMany , 用 hasOne 要指定 lgc= 1 或者2 +Hotelinfo.hasMany(Country, { as: 'country', sourceKey: 'country_code', foreignKey: 'id', onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }); // 多语种, 所以实际是 hasMany , 用 hasOne 要指定 lgc= 1 或者2 +// Hotelinfo2.belongsTo(Hotelinfo, { targetKey: 'hotel_id', foreignKey: 'hotel_id', onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }); + +class Heytrip { + /** + * 搜索酒店 + */ + hotelSearch = async (keyword, options) => { + const keywordSplit = keyword.split(' '); + const keywordWhere = (field) => keywordSplit.map((word) => Sequelize.where(Sequelize.fn('instr', Sequelize.col(field), word), { [Op.gt]: 0 })); + // 'hotelinfo.address' + const keywordSearch = ['hotelinfo.hotel_name', ].map((field) => ({ [Op.and]: keywordWhere(field) })); + const keywordOrder = ['hotelinfo.hotel_name',].reduce((ro, field) => ro.concat(keywordSplit.map((word) => Sequelize.fn('instr', Sequelize.col(field), word))), []); + + // const keywordSearchCount = ['hi2_hotel_name', 'hi2_address'].map((field) => ({ [Op.and]: keywordWhere(field) })); + // const countRows = await Hotelinfo2.count({ + // where: { + // [Op.or]: keywordSearchCount, + // }, + // // distinct: true, + // group: ['hotel_id'], + // }); + // const count = countRows.length; + // const findIds = countRows.map((item) => item.hotel_id); + + const { count, rows } = await Hotelinfo.findAndCountAll({ + // const [ rows ] = await Hotelinfo.findAll({ + include: [ + { + model: Hotelinfo2, + as: 'lgc_info', + attributes: [ + ['hi2_sn', 'sn'], + 'hotel_id', + 'lgc', + 'locale', + ['hi2_hotel_name', 'hotel_name'], + ['hi2_address', 'address'], + ['hi2_description', 'description'], + ['h2_review_desc', 'review_desc'], + ['h2_location_highlight_desc', 'location_highlight_desc'], + ], + required: false, + separate: true, + }, + { model: City, as: 'city', attributes: ['id', 'name'], where: { lgc: 2 }, required: false, separate: true, }, + { model: Country, as: 'country', attributes: ['id', 'name'], where: { lgc: 2 }, required: false, separate: true, }, + ], + // include: [{ model: Hotelinfo2, as: 'h2', required: true }], + where: { + [Op.or]: keywordSearch, + // hotel_id: findIds, + }, + order: keywordOrder, + ...options, + // raw: true, + nest: true, + }); + + return { count, rows: rows.map((item) => item.dataValues) }; + // return { count, rows }; + }; + + getLastPageIndex = async () => { + const ret = await HeytripIds.max('page_index'); + return ret; + }; + + syncAids = async () => { + const lastPageIndex = await this.getLastPageIndex(); + const pageIndex = lastPageIndex + 1; + + const ids = await AvailableAccommodationIds(pageIndex); + if (isEmpty(ids)) { + return { + nextPage: false, + pageIndex, + }; + } + + const insertRows = ids.map((id) => ({ hotel_id: id, page_index: pageIndex })); + await HeytripIds.bulkCreate(insertRows); + + return { + nextPage: true, + pageIndex, + }; + }; + + newHotels = async () => { + const [rows] = await Sequelize.query( + 'SELECT i.hotel_id, IFNULL(h.hi_sn ,0) info_exists FROM heytrip_ids as i LEFT JOIN hotelinfo AS h ON h.hotel_id = i.hotel_id WHERE h.hi_sn IS NULL AND update_flag != 0 ORDER BY info_exists LIMIT 10' + ); + return rows; + }; + + newHotelsLgc = async (lgc) => { + // const [rows] = await Sequelize.query( + // `SELECT h.hotel_id + // FROM hotelinfo AS h + // LEFT JOIN hotelinfo2 AS h2 ON h.hotel_id =h2.hotel_id + // AND h2.lgc = ${lgc} + // WHERE h2.hi2_sn IS NULL LIMIT 10` + // ); + const [rows] = await Sequelize.query( + `SELECT i.hotel_id ,IFNULL(h.hi2_sn, 0) info_exists + FROM heytrip_ids AS i + LEFT JOIN hotelinfo2 AS h ON h.hotel_id = i.hotel_id + AND h.lgc = ${lgc} + WHERE h.hi2_sn IS NULL + AND update_flag != 99 + ORDER BY info_exists LIMIT 10` + ); + const res = await this.syncInitHotelLgcDetailsAction(rows, LGC_MAPPED[lgc]); + return res; + }; + + chinaHotels = async () => { + const [rows] = await Sequelize.query( + 'SELECT i.hotel_id, IFNULL(h.hi_sn ,0) info_exists FROM heytrip_ids as i LEFT JOIN hotelinfo AS h ON h.hotel_id = i.hotel_id WHERE h.hi_sn IS NULL AND update_flag != 0 AND i.hotel_id > 20000000 ORDER BY info_exists LIMIT 10' + ); + const res = await this.syncInitHotelDetailsAction(rows, LGC_MAPPED['2']); + return res; + }; + chinaHotelsLgc2 = async (lgc) => { + // const [rows] = await Sequelize.query( + // `SELECT h.hotel_id + // FROM hotelinfo AS h + // LEFT JOIN hotelinfo2 AS h2 ON h.hotel_id =h2.hotel_id + // AND h2.lgc = ${lgc} + // WHERE h2.hi2_sn IS NULL LIMIT 10` + // ); + const [rows] = await Sequelize.query( + `SELECT i.hotel_id ,IFNULL(h.hi2_sn, 0) info_exists + FROM heytrip_ids AS i + INNER JOIN hotelinfo AS h1 ON h1.hotel_id =i.hotel_id + LEFT JOIN hotelinfo2 AS h ON h.hotel_id = i.hotel_id + AND h.lgc = ${lgc} + WHERE h.hi2_sn IS NULL + AND update_flag != 99 + AND h1.country_code ='CN' + -- AND i.hotel_id > 20000000 + ORDER BY info_exists LIMIT 10` + ); + const res = await this.syncInitHotelLgcDetailsAction(rows, LGC_MAPPED[lgc]); + return res; + }; + + syncInitHotelDetailsAction = async (rows, lgcObj = LGC_MAPPED[DEFAULT_LGC]) => { + let allIds = []; + try { + + allIds = rows.map((item) => item.hotel_id); + const updateIds = rows.filter((item) => item.info_exists !== 0).map((item) => item.hotel_id); + const newIds = rows.filter((item) => item.info_exists === 0).map((item) => item.hotel_id); + + if (isEmpty(rows)) { + return { next: !isEmpty(allIds), data: allIds }; + } + const res = await AccommodationsDetails({ + Language: lgcObj.locale, + AccommodationIds: allIds, + }); + + const resIds = res.map((item) => item.HotelId); + // hotel info + const insertData = extractDetails(res, lgcObj); + // return insertData; // debug: 0 + + /** 开始Database */ + const sequelizeOptions = { logging: false }; + const result = await Sequelize.transaction(async t => { + let Info; + if ( ! isEmpty(insertData.info)) Info = await Hotelinfo.bulkCreate(insertData.info, sequelizeOptions); + if ( ! isEmpty(insertData.info2)) await Hotelinfo2.bulkCreate(insertData.info2, sequelizeOptions); + if ( ! isEmpty(insertData.rooms)) await Rooms.bulkCreate(insertData.rooms, sequelizeOptions); + if ( ! isEmpty(insertData.images)) await Images.bulkCreate(insertData.images, sequelizeOptions); + if ( ! isEmpty(insertData.facility)) await Facility.bulkCreate(insertData.facility, sequelizeOptions); + if ( ! isEmpty(insertData.infos)) await Informations.bulkCreate(insertData.infos, sequelizeOptions); + if ( ! isEmpty(insertData.reviews)) await Reviews.bulkCreate(insertData.reviews, sequelizeOptions); + if ( ! isEmpty(insertData.reviews_summaries)) await ReviewsSummaries.bulkCreate(insertData.reviews_summaries, sequelizeOptions); + if ( ! isEmpty(insertData.locations)) await Locations.bulkCreate(insertData.locations, sequelizeOptions); + if ( ! isEmpty(allIds)) await HeytripIds.update({ update_flag: 0 }, { where: { hotel_id: allIds } }); + return Info; + }); + + return { next: !isEmpty(allIds), data: allIds }; + + } catch (error) { + console.log(error); + + return { next: false, data: allIds }; + } + } + + syncInitHotelLgcDetailsAction = async (rows, lgcObj = LGC_MAPPED[DEFAULT_LGC]) => { + let allIds = []; + try { + + allIds = rows.map((item) => item.hotel_id); + if (isEmpty(rows)) { + return { next: !isEmpty(allIds), data: allIds }; + } + const _BaseInfoExists = await Hotelinfo.findAll({ where: { hotel_id: allIds } }); + const updateIds = _BaseInfoExists.map((item) => `${item.hotel_id}`); + console.log('updateIds', updateIds); + + + const res = await AccommodationsDetails({ + Language: lgcObj.locale, + AccommodationIds: allIds, + }); + + const resIds = res.map((item) => item.HotelId); + // hotel info + const insertData = resolveDetails(res, lgcObj); + // return insertData; // debug: 0 + + /** 开始Database */ + const result = await Sequelize.transaction(async transaction => { + const sequelizeOptions = { logging: false, transaction }; + let Info; + + const newInfo = insertData.info.filter(iitem => !updateIds.includes(`${iitem.hotel_id}`)); + console.log('newInfo', newInfo.map(xx => xx.hotel_id)); + if ( ! isEmpty(newInfo)) Info = await Hotelinfo.bulkCreate(newInfo, sequelizeOptions); + + if ( ! isEmpty(insertData.info2)) await Hotelinfo2.bulkCreate(insertData.info2, sequelizeOptions); + if ( ! isEmpty(insertData.rooms)) await Rooms.bulkCreate(insertData.rooms, sequelizeOptions); + if ( ! isEmpty(insertData.images)) await Images.bulkCreate(insertData.images, sequelizeOptions); + if ( ! isEmpty(insertData.facility)) await Facility.bulkCreate(insertData.facility, sequelizeOptions); + if ( ! isEmpty(insertData.infos)) await Informations.bulkCreate(insertData.infos, sequelizeOptions); + if ( ! isEmpty(insertData.reviews)) await Reviews.bulkCreate(insertData.reviews, sequelizeOptions); + if ( ! isEmpty(insertData.reviews_summaries)) await ReviewsSummaries.bulkCreate(insertData.reviews_summaries, sequelizeOptions); + if ( ! isEmpty(insertData.locations)) await Locations.bulkCreate(insertData.locations, sequelizeOptions); + + if ( ! isEmpty(newInfo)) await HeytripIds.update({ update_flag: 0 }, { where: { hotel_id: newInfo.map(x => x.hotel_id) } }); + + return Info; + }); + + return { next: !isEmpty(allIds), data: allIds }; + + } catch (error) { + console.log(error); + + return { next: false, data: allIds }; + } + }; + + getHotelAvailability = async (param) => { + const { hotel_id, checkin, checkout, adults, children_ages, rooms, nationality } = param; + const paramBody = { + Language: 'en-US', + // Language: 'zh-CN', + AccommodationIds: [Number(hotel_id)], + CheckInDate: checkin, + CheckOutDate: checkout, + Nationality: nationality || 'CN', // 默认取中国报价 + NumberOfAdults: adults || 1, + ChildrenAges: children_ages || null, // 入住每个儿童年龄 [6,8] + // ChildrenAges: null, + NumberOfRooms: rooms || 1, + Currency: 'CNY', + }; + const _quoteRes = await Availability(paramBody); + const quoteRes = resolveRatePlans(_quoteRes); + return quoteRes; + }; +} +module.exports = new Heytrip(); diff --git a/server/services/requestLogsService.js b/server/services/requestLogsService.js new file mode 100644 index 0000000..44e8f39 --- /dev/null +++ b/server/services/requestLogsService.js @@ -0,0 +1,17 @@ +const db = require('../config/db'); +const initModels = require('./../models/init-models'); + +const Sequelize = db.sequelize; +const models = initModels(Sequelize); + +const Logs = models.requestLogsModel; + +// Logs.sync({ force: false }); + +class requestLogs { + static async create(data) { + return await Logs.create(data, { logging: false }); + } +} + +module.exports = requestLogs; diff --git a/server/utils/commons.js b/server/utils/commons.js new file mode 100644 index 0000000..103ca34 --- /dev/null +++ b/server/utils/commons.js @@ -0,0 +1,683 @@ +function copy(obj) { + return JSON.parse(JSON.stringify(obj)); +} + +function formatDate(date) { + if (isEmpty(date)) { + return 'NaN'; + } + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const monthStr = ('' + month).padStart(2, 0); + const dayStr = ('' + day).padStart(2, 0); + const formatted = year + '-' + monthStr + '-' + dayStr; + + return formatted; +} + +function formatTime(date) { + const hours = date.getHours(); + const minutes = date.getMinutes(); + + const hoursStr = ('' + hours).padStart(2, 0); + const minutesStr = ('' + minutes).padStart(2, 0); + const formatted = hoursStr + ':' + minutesStr; + + return formatted; +} + +function formatDatetime(date) { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const monthStr = ('' + month).padStart(2, 0); + const dayStr = ('' + day).padStart(2, 0); + + const hours = date.getHours(); + const minutes = date.getMinutes(); + + const hoursStr = ('' + hours).padStart(2, 0); + const minutesStr = ('' + minutes).padStart(2, 0); + + const formatted = year + '-' + monthStr + '-' + dayStr + ' ' + hoursStr + ':' + minutesStr; + + return formatted; +} + +function camelCase(name) { + return name.substr(0, 1).toLowerCase() + name.substr(1); +} + +class UrlBuilder { + constructor(url) { + this.url = url; + this.paramList = []; + } + + append(name, value) { + if (isNotEmpty(value)) { + this.paramList.push({ name: name, value: value }); + } + return this; + } + + build() { + this.paramList.forEach((e, i, a) => { + if (i === 0) { + this.url += '?'; + } else { + this.url += '&'; + } + this.url += e.name + '=' + e.value; + }); + return this.url; + } +} + +function isNotEmpty(val) { + return val !== undefined && val !== null && val !== ''; +} + +function prepareUrl(url) { + return new UrlBuilder(url); +} + +function throttle(fn, delay, atleast) { + let timeout = null, + startTime = new Date(); + return function () { + let curTime = new Date(); + clearTimeout(timeout); + if (curTime - startTime >= atleast) { + fn(); + startTime = curTime; + } else { + timeout = setTimeout(fn, delay); + } + }; +} + +function clickUrl(url) { + const httpLink = document.createElement('a'); + httpLink.href = url; + httpLink.target = '_blank'; + httpLink.click(); +} + +function escape2Html(str) { + var temp = document.createElement('div'); + temp.innerHTML = str; + var output = temp.innerText || temp.textContent; + temp = null; + return output; +} + +function formatPrice(price) { + return Math.ceil(price).toLocaleString(); +} + +function formatPercent(number) { + return Math.round(number * 100) + '%'; +} + +/** + * ! 不支持计算 Set 或 Map + * @param {*} val + * @example + * true if: 0, [], {}, null, '', undefined + * false if: 'false', 'undefined' + */ +function isEmpty(val) { + // return val === undefined || val === null || val === ""; + return [Object, Array].includes((val || {}).constructor) && !Object.entries(val || {}).length; +} +/** + * 数组排序 + */ +const sortBy = (key) => { + return (a, b) => (getNestedValue(a, key) > getNestedValue(b, key) ? 1 : getNestedValue(b, key) > getNestedValue(a, key) ? -1 : 0); +}; + +/** + * Object排序keys + */ +const sortKeys = (obj) => + Object.keys(obj) + .sort() + .reduce((a, k2) => ({ ...a, [k2]: obj[k2] }), {}); + +/** + * 数组排序, 给定排序数组 + * @param {array} items 需要排序的数组 + * @param {array} keyName 排序的key + * @param {array} keyOrder 给定排序 + * @returns + */ +const sortArrayByOrder = (items, keyName, keyOrder) => { + return items.sort((a, b) => { + return keyOrder.indexOf(a[keyName]) - keyOrder.indexOf(b[keyName]); + }); +}; +/** + * 合并Object, 递归地 + */ +function merge(...objects) { + const isDeep = objects.some((obj) => obj !== null && typeof obj === 'object'); + + const result = objects[0] || (isDeep ? {} : objects[0]); + + for (let i = 1; i < objects.length; i++) { + const obj = objects[i]; + + if (!obj) continue; + + Object.keys(obj).forEach((key) => { + const val = obj[key]; + + if (isDeep) { + if (Array.isArray(val)) { + result[key] = [].concat(Array.isArray(result[key]) ? result[key] : [result[key]], val); + } else if (typeof val === 'object') { + result[key] = merge(result[key], val); + } else { + result[key] = val; + } + } else { + result[key] = typeof val === 'boolean' ? val : result[key]; + } + }); + } + + return result; +} + +/** + * 数组分组 + * - 相当于 lodash 的 _.groupBy + * @see https://www.lodashjs.com/docs/lodash.groupBy#_groupbycollection-iteratee_identity + */ +function groupBy(array = [], callback) { + return array.reduce((groups, item) => { + const key = typeof callback === 'function' ? callback(item) : item[callback]; + + if (!groups[key]) { + groups[key] = []; + } + + groups[key].push(item); + return groups; + }, {}); +} + +/** + * 创建一个从 object 中选中的属性的对象。 + * @param {*} object + * @param {array} keys + */ +function pick(object, keys) { + return keys.reduce((obj, key) => { + if (object && Object.prototype.hasOwnProperty.call(object, key)) { + obj[key] = object[key]; + } + return obj; + }, {}); +} + +/** + * 返回对象的副本,经过筛选以省略指定的键。 + * @param {*} object + * @param {string[]} keysToOmit + * @returns + */ +function omit(object, keysToOmit) { + return Object.fromEntries(Object.entries(object).filter(([key]) => !keysToOmit.includes(key))); +} + +/** + * 深拷贝 + */ +function cloneDeep(value, visited = new WeakMap()) { + // 处理循环引用 + if (visited.has(value)) { + return visited.get(value); + } + + // 特殊对象和基本类型处理 + if (value instanceof Date) { + return new Date(value); + } + if (value instanceof RegExp) { + return new RegExp(value.source, value.flags); + } + if (value === null || typeof value !== 'object') { + return value; + } + + // 创建一个新的WeakMap项以避免内存泄漏 + let result; + if (Array.isArray(value)) { + result = []; + visited.set(value, result); + } else { + result = {}; + visited.set(value, result); + } + + for (const key of Object.getOwnPropertySymbols(value)) { + // 处理Symbol属性 + result[key] = cloneDeep(value[key], visited); + } + + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + // 处理普通属性 + result[key] = cloneDeep(value[key], visited); + } + } + + return result; +} +/** + * 向零四舍五入, 固定精度设置 + */ +function curriedFix(precision = 0) { + return function (number) { + // Shift number by precision places + const shift = Math.pow(10, precision); + const shiftedNumber = number * shift; + + // Round to nearest integer + const roundedNumber = Math.round(shiftedNumber); + + // Shift back decimal place + return roundedNumber / shift; + }; +} +/** + * 向零四舍五入, 保留2位小数 + */ +const fixTo2Decimals = curriedFix(2); +/** + * 向零四舍五入, 保留4位小数 + */ +const fixTo4Decimals = curriedFix(4); + +const fixTo1Decimals = curriedFix(1); +const fixToInt = curriedFix(0); + +/** + * 创建一个按大小分组的元素数组 + */ +const chunk = (input = [], size = 0) => { + return input.reduce((arr, item, idx) => { + return idx % size === 0 ? [...arr, [item]] : [...arr.slice(0, -1), [...arr.slice(-1)[0], item]]; + }, []); +}; + +/** + * 映射 + * @example + * const keyMap = { + a: [{key: 'a1'}, {key: 'a2', transform: v => v * 2}], + b: {key: 'b1'} + }; + const result = objectMapper({a: 1, b: 3}, keyMap); + // result = {a1: 1, a2: 2, b1: 3} + * + */ +function objectMapper(input, keyMap, keep = true) { + // Loop through array mapping + if (Array.isArray(input)) { + return input.map((obj) => objectMapper(obj, keyMap)); + } + + if (typeof input === 'object') { + const mappedObj = {}; + + Object.keys(input).forEach((key) => { + // Keep original keys not in keyMap + if (!keyMap[key] && keep) { + mappedObj[key] = input[key]; + } + // Handle array of maps + if (Array.isArray(keyMap[key])) { + keyMap[key].forEach((map) => { + let value = input[key]; + if (map.transform) value = map.transform(value); + mappedObj[map.key] = value; + }); + + // Handle single map + } else { + const map = keyMap[key]; + if (map) { + let value = input[key]; + if (map.transform) value = map.transform(value); + mappedObj[map.key || map] = value; + } + } + }); + + return mappedObj; + } + + return input; +} + +/** + * 创建一个对应于对象路径的值数组 + */ +function at(obj, path) { + let result; + if (Array.isArray(obj)) { + // array case + const indexes = path.split('.').map((i) => parseInt(i)); + result = []; + for (let i = 0; i < indexes.length; i++) { + result.push(obj[indexes[i]]); + } + } else { + // object case + const indexes = path.split('.').map((i) => i); + result = [obj]; + for (let i = 0; i < indexes.length; i++) { + result = [result[0]?.[indexes[i]] || undefined]; + } + } + return result; +} +/** + * 删除 null/undefined + */ +function flush(collection) { + let result, len, i; + if (!collection) { + return undefined; + } + if (Array.isArray(collection)) { + result = []; + len = collection.length; + for (i = 0; i < len; i++) { + const elem = collection[i]; + if (elem != null) { + result.push(elem); + } + } + return result; + } + if (typeof collection === 'object') { + result = {}; + const keys = Object.keys(collection); + len = keys.length; + for (i = 0; i < len; i++) { + const key = keys[i]; + const value = collection[key]; + if (value != null) { + result[key] = value; + } + } + return result; + } + return undefined; +} + +/** + * 千分位 格式化数字 + */ +const numberFormatter = (number) => { + return new Intl.NumberFormat().format(number); +}; + +/** + * @example + * const obj = { a: { b: 'c' } }; + * const keyArr = ['a', 'b']; + * getNestedValue(obj, keyArr); // Returns: 'c' + */ +const getNestedValue = (obj, keyArr) => { + return keyArr.reduce((acc, curr) => { + return acc && Object.prototype.hasOwnProperty.call(acc, curr) ? acc[curr] : undefined; + // return acc && acc[curr]; + }, obj); +}; + +/** + * 计算笛卡尔积 + */ +const cartesianProductArray = (arr, sep = '_', index = 0, prefix = '') => { + let result = []; + if (index === arr.length) { + return [prefix]; + } + arr[index].forEach((item) => { + result = result.concat(cartesianProductArray(arr, sep, index + 1, prefix ? `${prefix}${sep}${item}` : `${item}`)); + }); + return result; +}; + +const stringToColour = (str) => { + var hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + var colour = '#'; + for (let i = 0; i < 3; i++) { + var value = (hash >> (i * 8)) & 0xff; + value = (value % 150) + 50; + colour += ('00' + value.toString(16)).substr(-2); + } + return colour; +}; + +const debounce = (func, wait, immediate) => { + var timeout; + return function () { + var context = this, + args = arguments; + clearTimeout(timeout); + if (immediate && !timeout) func.apply(context, args); + timeout = setTimeout(function () { + timeout = null; + if (!immediate) func.apply(context, args); + }, wait); + }; +}; + +const removeFormattingChars = (str) => { + const regex = /[\r\n\t\v\f]/g; + str = str.replace(regex, ' '); + // Replace more than four consecutive spaces with a single space + str = str.replace(/\s{4,}/g, ' '); + return str; +}; + +const olog = (text, ...args) => { + console.log(`%c ${text} `, 'background:#fb923c ; padding: 1px; border-radius: 3px; color: #fff', ...args); +}; + +const sanitizeFilename = (str) => { + // Remove whitespace and replace with hyphens + str = str.replace(/\s+/g, '-'); + // Remove invalid characters and replace with hyphens + str = str.replace(/[^a-zA-Z0-9.-]/g, '-'); + // Replace consecutive hyphens with a single hyphen + str = str.replace(/-+/g, '-'); + // Trim leading and trailing hyphens + str = str.replace(/^-+|-+$/g, ''); + return str; +}; + +const formatBytes = (bytes, decimals = 2) => { + if (bytes === 0) return ''; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}; +const calcCacheSizes = async () => { + try { + let swCacheSize = 0; + let diskCacheSize = 0; + let indexedDBSize = 0; + + // 1. Get the service worker cache size + if ('caches' in window) { + const cacheNames = await caches.keys(); + for (const name of cacheNames) { + const cache = await caches.open(name); + const requests = await cache.keys(); + for (const request of requests) { + const response = await cache.match(request); + swCacheSize += Number(response.headers.get('Content-Length')) || 0; + } + } + } + + // 2. Get the disk cache size + // const diskCacheName = 'disk-cache'; + // const diskCache = await caches.open(diskCacheName); + // const diskCacheKeys = await diskCache.keys(); + // for (const request of diskCacheKeys) { + // const response = await diskCache.match(request); + // diskCacheSize += Number(response.headers.get('Content-Length')) || 0; + // } + + // 3. Get the IndexedDB cache size + // const indexedDBNames = await window.indexedDB.databases(); + // for (const dbName of indexedDBNames) { + // const db = await window.indexedDB.open(dbName.name); + // const objectStoreNames = db.objectStoreNames; + + // if (objectStoreNames !== undefined) { + // const objectStores = Array.from(objectStoreNames).map((storeName) => db.transaction([storeName], 'readonly').objectStore(storeName)); + + // for (const objectStore of objectStores) { + // const request = objectStore.count(); + // request.onsuccess = () => { + // indexedDBSize += request.result; + // }; + // } + // } + // } + + return { swCacheSize, diskCacheSize, indexedDBSize, totalSize: Number(swCacheSize) + Number(diskCacheSize) + indexedDBSize }; + } catch (error) { + console.error('Error getting cache sizes:', error); + } +}; + +const clearAllCaches = async (cb) => { + try { + // 1. Clear the service worker cache + if ('caches' in window) { + // if (navigator.serviceWorker) { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map((name) => caches.delete(name))); + } + + // 2. Clear the disk cache (HTTP cache) + // const diskCacheName = 'disk-cache'; + // await window.caches.delete(diskCacheName); + // const diskCache = await window.caches.open(diskCacheName); + // const diskCacheKeys = await diskCache.keys(); + // await Promise.all(diskCacheKeys.map((request) => diskCache.delete(request))); + + // 3. Clear the IndexedDB cache + const indexedDBNames = await window.indexedDB.databases(); + await Promise.all(indexedDBNames.map((dbName) => window.indexedDB.deleteDatabase(dbName.name))); + + // Unregister the service worker + const registration = await navigator.serviceWorker.getRegistration(); + if (registration) { + await registration.unregister(); + console.log('Service worker unregistered'); + } else { + console.log('No service worker registered'); + } + if (typeof cb === 'function') { + cb(); + } + } catch (error) { + console.error('Error clearing caches or unregistering service worker:', error); + } +}; + +const loadScript = (src) => { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.onload = resolve; + script.onerror = reject; + script.crossOrigin = 'anonymous'; + script.src = src; + if (document.head.append) { + document.head.append(script); + } else { + document.getElementsByTagName('head')[0].appendChild(script); + } + }); +}; + +//格式化为冒号时间,2010转为20:10 +const formatColonTime = (text) => { + const hours = text.substring(0, 2); + const minutes = text.substring(2); + return `${hours}:${minutes}`; +}; + +// 生成唯一 36 位数字,用于新增记录 ID 赋值,React key 属性等 +const generateId = () => new Date().getTime().toString(36) + Math.random().toString(36).substring(2, 9); + +module.exports = { + copy, + formatDate, + formatTime, + formatDatetime, + camelCase, + prepareUrl, + throttle, + clickUrl, + escape2Html, + formatPrice, + formatPercent, + isEmpty, + isNotEmpty, + sortBy, + sortKeys, + sortArrayByOrder, + merge, + groupBy, + pick, + omit, + cloneDeep, + curriedFix, + fixTo2Decimals, + fixTo4Decimals, + fixTo1Decimals, + fixToInt, + chunk, + objectMapper, + at, + flush, + numberFormatter, + getNestedValue, + cartesianProductArray, + stringToColour, + debounce, + removeFormattingChars, + olog, + sanitizeFilename, + formatBytes, + calcCacheSizes, + clearAllCaches, + loadScript, + formatColonTime, + generateId, +}; diff --git a/server/vendor/heytrip.js b/server/vendor/heytrip.js new file mode 100644 index 0000000..b91f58d --- /dev/null +++ b/server/vendor/heytrip.js @@ -0,0 +1,87 @@ +const crypto = require('crypto'); +const axios = require('axios'); +const { get, post } = axios; + +const { HEYTRIP_API, HEYTRIP_API_PROD } = require('./../config/constants'); +const { chunk, isEmpty } = require('../utils/commons'); + +const make_token = () => { + var apiKey = '18daad53d0ec4003a207c41ddaf63b78'; + var secret = 'f76e547e55964812bf94cc0d31f74333'; + var timestamp = Math.round(new Date().getTime() / 1000); + var hash = crypto + .createHash('sha512') + .update(apiKey + secret + timestamp) + .digest('hex'); + var authHeaderValue = 'Bearer apikey=' + apiKey + ',signature=' + hash + ',timestamp=' + timestamp; + return authHeaderValue; +}; +const AvailableAccommodationIds = async (pageIndex) => { + const response = await get(`${HEYTRIP_API_PROD}/AvailableAccommodationIds?pageIndex=${pageIndex}`, { + headers: { + Authorization: make_token(), + }, + }); + console.log('Call pageIndex', pageIndex, response.data.TotalPage); + return response.data.Data; +}; + +const AccommodationsDetails = async (body) => { + if (isEmpty(body.AccommodationIds)) { + return []; + } + const response = await post( + `${HEYTRIP_API_PROD}/AccommodationsDetails`, + { ...body }, + { + headers: { + Authorization: make_token(), + }, + } + ); + return Object.values(response.data.Data || {}); +}; + +const Availability = async (body) => { + // console.log('Call Heytrip'); + const response = await post( + `${HEYTRIP_API_PROD}/Availability`, + { ...body }, + { + headers: { + Authorization: make_token(), + }, + } + ); + // console.log(response.config); + return response.data.Data || []; +}; + +const QuotedHotelsPrice = async (body) => { + const idsChunk = body.hotelIds.length > 10 ? chunk(body.hotelIds, 10) : [body.hotelIds]; + let quoteRes = []; + for await (const piece of idsChunk) { + const response = await post( + `${HEYTRIP_API_PROD}/QuotedHotelsPrice`, + { ...body, hotelIds: piece }, + { + headers: { + Authorization: make_token(), + }, + } + ); + quoteRes = quoteRes.concat(response.data.Data || []); + } + return quoteRes; +}; + +const Country = async (param) => {}; + +const City = async (param) => {}; + +module.exports = { + AvailableAccommodationIds, + AccommodationsDetails, + Availability, + QuotedHotelsPrice, +}; diff --git a/server/views/error.ejs b/server/views/error.ejs new file mode 100644 index 0000000..7cf94ed --- /dev/null +++ b/server/views/error.ejs @@ -0,0 +1,3 @@ +

<%= message %>

+

<%= error.status %>

+
<%= error.stack %>
diff --git a/server/views/index.ejs b/server/views/index.ejs new file mode 100644 index 0000000..cc50d13 --- /dev/null +++ b/server/views/index.ejs @@ -0,0 +1,11 @@ + + + + <%= title %> + + + +

<%= title %>

+

EJS Welcome to <%= title %>

+ +