From b958813406a55d59cdd5d9a321b535ac8e135ad0 Mon Sep 17 00:00:00 2001 From: Tiga Date: Mon, 12 Oct 2026 17:59:54 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=95=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/context/createMockContext.js | 33 +-- src/ldap/createMockLdapService.js | 11 - src/routes/ewl/bal/playerPromotionRedeem.js | 39 +--- src/routes/ewl/bal/redeemedPromotionVoid.js | 219 ++------------------ src/routes/ewl/msgw/contentSend.js | 36 ++++ src/routes/registerRoutes.js | 2 + src/server.js | 3 - 7 files changed, 64 insertions(+), 279 deletions(-) create mode 100644 src/routes/ewl/msgw/contentSend.js diff --git a/src/context/createMockContext.js b/src/context/createMockContext.js index 2707962..21d131a 100644 --- a/src/context/createMockContext.js +++ b/src/context/createMockContext.js @@ -168,9 +168,6 @@ function createMockContext() { process.env.MOCK_PROMOTION_REDEMPTION_INITIATE_SOURCE || 'PROMOTION_MODULE' const promotionRedemptionSourceBucket = (process.env.MOCK_PROMOTION_REDEMPTION_SOURCE_BUCKET || 'm-comp').trim().toLowerCase() - const promotionRedemptionSimulateErrorAfterBridge = - (process.env.MOCK_PROMOTION_REDEMPTION_SIMULATE_ERROR_AFTER_BRIDGE || 'false').toLowerCase() === - 'true' const defaultPromotionRedemptionIdStart = resolveDefaultPromotionRedemptionIdStart() const promotionRedemptionIdStartRaw = Number(process.env.MOCK_PROMOTION_REDEMPTION_ID_START || defaultPromotionRedemptionIdStart) @@ -209,15 +206,11 @@ function createMockContext() { process.env.MOCK_LDAP_ALLOW_ANY_USER_GROUP_CN || 'SG-APP-EWALLET-SUPER-ADMIN' const ldapAllowAnyUserMailDomain = process.env.MOCK_LDAP_ALLOW_ANY_USER_MAIL_DOMAIN || 'macausjm-glp.com' - const ldapForceNoSuchObject = - (process.env.MOCK_LDAP_FORCE_NO_SUCH_OBJECT || '').toLowerCase() === 'true' - const ldapForceNoSuchObjectMatch = - process.env.MOCK_LDAP_FORCE_NO_SUCH_OBJECT_MATCH || 'OU=macauin-glp,DC=com' const mockProfile = { firstName: process.env.MOCK_MEMBER_FIRST_NAME || 'Gary', lastName: process.env.MOCK_MEMBER_LAST_NAME || 'Chan', - nickName: process.env.MOCK_MEMBER_NICK_NAME || 'xxx', + nickName: process.env.MOCK_MEMBER_NICK_NAME || '陳加里', tier: process.env.MOCK_MEMBER_TIER || 'Novel', eBonus: process.env.MOCK_MEMBER_EBONUS || '0', phoneNum: process.env.MOCK_MEMBER_PHONE || '+853-68282140', @@ -309,7 +302,7 @@ function createMockContext() { firstName: 'Gary', lastName: 'Chan', middleName: 'David', - nickName: 'xxxx', + nickName: '陳加里', image: '', }, attribute: { @@ -1138,22 +1131,6 @@ function createMockContext() { return false } - function findRecordedRedeemedPromotionOutcome(redeemedId) { - const numericRedeemedId = Number(redeemedId) - if (!Number.isFinite(numericRedeemedId)) { - return null - } - - for (const outcomeList of recordedRedeemedPromotionsByAccount.values()) { - const found = outcomeList.find((item) => item?.redemptionId === numericRedeemedId) - if (found) { - return found - } - } - - return null - } - // Task 10766: Return a deterministic redeem failure for the dedicated member so error rendering can be checked reliably. function getPlayerPromotionRedeemMock(accountNum) { if (isTask10766Account(accountNum)) { @@ -1245,7 +1222,6 @@ function createMockContext() { promotionRedemptionBridgeTimeoutMs, promotionRedemptionInitiateSource, promotionRedemptionSourceBucket, - promotionRedemptionSimulateErrorAfterBridge, redeemedPromotionVoidErrorCode, redeemedPromotionVoidErrorMsg, redeemedPromotionVoidResult, @@ -1263,9 +1239,7 @@ function createMockContext() { ldapAllowAnyUserOu, ldapAllowAnyUserGroupCn, ldapAllowAnyUserMailDomain, - ldapForceNoSuchObject, - ldapForceNoSuchObjectMatch, - }, + }, edipForceErrorCode: process.env.MOCK_EDIP_FORCE_ERROR_CODE || '', edipForceErrorMsg: process.env.MOCK_EDIP_FORCE_ERROR_MSG || 'Mock EDIP token error', edipRefreshTokenMustExist: @@ -1301,7 +1275,6 @@ function createMockContext() { recordRedeemedPromotionOutcome, getRecordedRedeemedPromotionOutcomes, voidRecordedRedeemedPromotionOutcome, - findRecordedRedeemedPromotionOutcome, getAccountSpecificRedeemedPromotionsFixture, cloneJsonValue, } diff --git a/src/ldap/createMockLdapService.js b/src/ldap/createMockLdapService.js index 562152b..07ea1be 100644 --- a/src/ldap/createMockLdapService.js +++ b/src/ldap/createMockLdapService.js @@ -13,8 +13,6 @@ function createMockLdapService({ ldapConfig }) { ldapAllowAnyUserOu, ldapAllowAnyUserGroupCn, ldapAllowAnyUserMailDomain, - ldapForceNoSuchObject, - ldapForceNoSuchObjectMatch, } = ldapConfig function normalizeDn(value) { @@ -457,15 +455,6 @@ function createMockLdapService({ ldapConfig }) { const searchBase = resolveSearchBase(req.dn?.toString()) const scope = req.scope || 'sub' - if (ldapForceNoSuchObject) { - // Reproduce QA's "LDAP error code 32 - NO_OBJECT" from real AD. - // The "best match of" suffix mirrors the AD DSID-0310028D / NameErr output seen on QA. - const match = ldapForceNoSuchObjectMatch || 'OU=macauin-glp,DC=com' - const error = new ldap.NoSuchObjectError() - error.message = `NO_OBJECT: best match of: '${match}'\n000001` - return next(error) - } - if (ldapAllowAnyUserMode) { let hasAnyModeResult = false const usernames = dedupeStringValues(extractEqFilterValues(req.filter, 'sAMAccountName')) diff --git a/src/routes/ewl/bal/playerPromotionRedeem.js b/src/routes/ewl/bal/playerPromotionRedeem.js index 11d4c43..22c1de1 100644 --- a/src/routes/ewl/bal/playerPromotionRedeem.js +++ b/src/routes/ewl/bal/playerPromotionRedeem.js @@ -268,10 +268,15 @@ function resolveTargetPrizeValue(prize, sourceBucket, promotion) { return bucketPrice } - // When the source bucket price is absent, leave the value unspecified (null) - // so the redemption payload sends valueUsed: null and eWallet computes it - // from its own configuration (instead of falling back to prizeValue). - return null + if (Number.isFinite(prize?.prizeValue)) { + return prize.prizeValue + } + + if (Number.isFinite(promotion?.toEarn1Unit)) { + return promotion.toEarn1Unit + } + + return 0 } function buildPromotionMetadata(requestData, promotion, redemptionId) { @@ -443,11 +448,7 @@ async function bridgePromotionRedeemToEwallet({ accountNum, backdate = false, }) { - const { log } = require('../../../logger') const requestData = requestBody?.data || {} - log( - `[BRIDGE] start account=${accountNum} promotionId=${requestData.promotionId} outcomeId=${requestData.outcomeId} backdate=${backdate}`, - ) const selection = context.resolvePromotionRedeemSelection( accountNum, requestData.promotionId, @@ -501,7 +502,6 @@ async function bridgePromotionRedeemToEwallet({ throw new Error('Promotion bridge login did not return an access token') } - log(`[BRIDGE] calling eWallet POST ${redemptionUrl} (sync)`) const redemptionResponseBody = await requestJson( redemptionUrl, createRequestOptions( @@ -518,12 +518,10 @@ async function bridgePromotionRedeemToEwallet({ context.promotionRedemptionBridgeTimeoutMs, ), ) - log(`[BRIDGE][SYNC-RAW] ${JSON.stringify(redemptionResponseBody)}`) const redemptionResponseData = unwrapApiResponseBody( redemptionResponseBody, 'Promotion bridge prize redemption', ) - log(`[BRIDGE][SYNC-DATA] ${JSON.stringify(redemptionResponseData)}`) if (!redemptionResponseData?.publicId) { throw new Error('Promotion bridge prize redemption did not return a publicId') @@ -557,10 +555,6 @@ function registerPlayerPromotionRedeemRoute({ app, context }) { ] routes.forEach((route) => app.post(route, async (req, res) => { - const { log } = require('../../../logger') - log( - `[REDEEM-RECV] ${route} account=${context.resolveAccountNum(req.body)} promotionId=${req.body?.data?.promotionId} outcomeId=${req.body?.data?.outcomeId}`, - ) const headerMergedRequestBody = mergeIssueTargetToWalletOverrides(req.body, req.headers) const accountNum = context.resolveAccountNum(headerMergedRequestBody) const normalizedRequestBody = mergeRuntimeIssueTargetToWalletOverrides( @@ -574,7 +568,6 @@ function registerPlayerPromotionRedeemRoute({ app, context }) { let errorMsg = redeemMock.errorMsg let result = redeemMock.result - let simulatePublicId = null if (result && !errorCode) { try { if (context.promotionRedemptionBridgeEnabled) { @@ -584,18 +577,7 @@ function registerPlayerPromotionRedeemRoute({ app, context }) { accountNum, backdate: backdateRedeemRequest, }) - if (context.promotionRedemptionSimulateErrorAfterBridge) { - // Plan C: simulate the promotion error AFTER the bridge created the - // eWallet redemption, but do NOT record a redeemed promotion in the - // Mock store so the history won't show a non-voided "success". - simulatePublicId = - bridgeResult.redeemedOutcome?.outcome?.redeemedList?.[0]?.transId || null - errorCode = 'SIMULATED_PROMO_REDEEM_ERROR' - errorMsg = `Simulated promotion redeem failure after eWallet sync created publicId=${simulatePublicId}` - result = false - } else { - context.recordRedeemedPromotionOutcome(accountNum, bridgeResult.redeemedOutcome) - } + context.recordRedeemedPromotionOutcome(accountNum, bridgeResult.redeemedOutcome) } else if (backdateRedeemRequest) { const mockResult = createMockPromotionRedeemResult({ context, @@ -617,7 +599,6 @@ function registerPlayerPromotionRedeemRoute({ app, context }) { errorMsg, accountNum, result, - ...(simulatePublicId ? { redemptionPublicId: simulatePublicId } : {}), }) })) } diff --git a/src/routes/ewl/bal/redeemedPromotionVoid.js b/src/routes/ewl/bal/redeemedPromotionVoid.js index 4cf2c06..8b9e67a 100644 --- a/src/routes/ewl/bal/redeemedPromotionVoid.js +++ b/src/routes/ewl/bal/redeemedPromotionVoid.js @@ -1,216 +1,23 @@ -const { log } = require('../../../logger') - -function joinUrl(baseUrl, pathname) { - const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/` - return new URL(pathname.replace(/^\//, ''), normalizedBaseUrl).toString() -} - -function createRequestOptions(method, body, headers, timeoutMs) { - return { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: AbortSignal.timeout(timeoutMs), - timeoutMs, - } -} - -async function requestJson(url, options) { - let response - try { - response = await fetch(url, options) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (error?.name === 'TimeoutError' || /aborted due to timeout/i.test(message)) { - throw new Error( - `Timeout after ${options.timeoutMs}ms on ${options.method || 'GET'} request for "${url}"`, - ) - } - throw error - } - const rawBody = await response.text() - if (!response.ok) { - throw new Error( - `HTTP ${response.status} ${response.statusText} on ${options.method || 'GET'} request for "${url}"${ - rawBody ? `: ${rawBody}` : '' - }`, - ) - } - if (!rawBody) { - return null - } - try { - return JSON.parse(rawBody) - } catch (error) { - throw new Error( - `Invalid JSON response on ${options.method || 'GET'} request for "${url}": "${rawBody}"`, - ) - } -} - -function unwrapApiResponseBody(responseBody, requestLabel) { - if (!responseBody || typeof responseBody !== 'object') { - throw new Error(`${requestLabel} returned an empty response body`) - } - if (Object.prototype.hasOwnProperty.call(responseBody, 'success')) { - if (responseBody.success === false) { - const detail = - responseBody.error?.detail || responseBody.message || `${requestLabel} returned an error` - throw new Error(detail) - } - return responseBody.data - } - return responseBody -} - -async function loginToEwallet(context) { - const baseUrl = context.promotionRedemptionBridgeBaseUrl - if (!baseUrl) { - throw new Error('promotionRedemptionBridgeBaseUrl is not configured; cannot bridge void to eWallet') - } - - const loginUsername = context.promotionRedemptionBridgeLoginUsername || 'mock.gateway' - const authUrl = joinUrl(baseUrl, '/api/v1/auth/login') - const authResp = await requestJson( - authUrl, - createRequestOptions( - 'POST', - { - username: loginUsername, - password: context.promotionRedemptionBridgeLoginPassword, - }, - { 'Content-Type': 'application/json' }, - context.promotionRedemptionBridgeTimeoutMs, - ), - ) - const authData = unwrapApiResponseBody(authResp, 'Promotion void bridge login') - const accessToken = authData?.accessToken - if (!accessToken) { - throw new Error('Promotion void bridge login did not return an access token') - } - return accessToken -} - -// Mirror playerPromotionRedeem.bridgePromotionRedeemToEwallet: in production the promotion / -// Bally void first calls eWallet's own prize-redemption void, and only after that succeeds does -// Bally mark its promotion record voided. This Mock simulates that cascade so the eWallet side -// (prize_redemption_transactions / prize_redemption_void_transactions) is voided before the -// recorded promotion file flips isVoided. -// -// A promotion outcome may contain multiple redeemed prizes, each with its own eWallet -// prize-redemption publicId (transId) nested under outcome.redeemedList. Void every one of them. -async function voidPrizeRedemptionInEwallet(context, { accessToken, transId, correlationId, voidReason, initiateType, initiateSource, schema, data }) { - const baseUrl = context.promotionRedemptionBridgeBaseUrl - const voidUrl = joinUrl(baseUrl, `/api/v1/prize-redemptions/${transId}/sync`) - log(`[BRIDGE][VOID] calling eWallet DELETE ${voidUrl} (correlationId=${correlationId}, transId=${transId})`) - const voidResp = await requestJson( - voidUrl, - createRequestOptions( - 'DELETE', - { - correlationId, - voidReason, - initiateType, - initiateSource, - }, - { - Authorization: `Bearer ${accessToken}`, - 'Client-Machine-Id': schema?.computerName || 'mock-gateway', - 'Content-Type': 'application/json', - 'Location-Code': data?.locationCode || 'MOCK', - 'Property-Code': data?.casinoCode || 'GLP', - 'X-Real-IP': schema?.ipAddress || '', - }, - context.promotionRedemptionBridgeTimeoutMs, - ), - ) - const voidData = unwrapApiResponseBody(voidResp, 'Promotion void bridge prize redemption void') - log(`[BRIDGE][VOID] eWallet void result overallStatus=${voidData?.overallStatus} (transId=${transId})`) - // eWallet treats FAILED / COMPENSATED / PENDING / PROCESSING as errors; a successful void - // comes back as VOIDED (or COMPLETED). Anything else is treated as success here. - const failedStatuses = ['FAILED', 'COMPENSATED', 'PENDING', 'PROCESSING'] - if (voidData?.overallStatus && failedStatuses.includes(voidData.overallStatus)) { - throw new Error(`eWallet prize redemption void finished with status ${voidData.overallStatus} (transId=${transId})`) - } - return voidData -} - function registerRedeemedPromotionVoidRoute({ app, context }) { const routes = [ '/ewl/promotion/v1/redeemedpromotion/void', '/ewl/bal/v1/redeemedpromotion/void', ] - routes.forEach((route) => - app.post( - route, - async (req, res) => { - const schema = req.body?.schema || {} - const data = req.body?.data || {} - const redeemedId = data.redeemedId - const loginId = data.loginId || '' + routes.forEach((route) => app.post(route, (req, res) => { + if (context.redeemedPromotionVoidResult) { + context.voidRecordedRedeemedPromotionOutcome( + req.body?.data?.redeemedId, + req.body?.data?.loginId || '', + ) + } - // eWallet void payload. The Bally promotion void request does not carry these, so derive them. - const correlationId = `VOID_PROMO_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` - const voidReason = data?.remark || 'PROMOTION_VOID' - const initiateType = data?.initiateType || 'SYSTEM' - const initiateSource = data?.initiateSource || 'WEB_APP' - - try { - // Resolve the eWallet prize-redemption publicId(s) (transId) tied to this promotion - // redemption. They are nested under outcome.redeemedList[].transId in the recorded - // promotion (NOT at the top level of the recorded outcome entry). A promotion outcome - // may contain multiple redeemed prizes, each with its own transId. - const recordedOutcome = context.findRecordedRedeemedPromotionOutcome - ? context.findRecordedRedeemedPromotionOutcome(redeemedId) - : null - - const transIds = recordedOutcome?.outcome?.redeemedList - ? recordedOutcome.outcome.redeemedList.map((r) => r.transId).filter(Boolean) - : [] - - if (transIds.length > 0 && context.promotionRedemptionBridgeBaseUrl) { - const accessToken = await loginToEwallet(context) - for (const transId of transIds) { - await voidPrizeRedemptionInEwallet(context, { - accessToken, - transId, - correlationId, - voidReason, - initiateType, - initiateSource, - schema, - data, - }) - } - log(`[BRIDGE][VOID] eWallet prize redemption void succeeded for transIds=${transIds.join(',')}`) - } else { - log( - `[BRIDGE][VOID] skipped eWallet void bridge (transIds=${JSON.stringify(transIds)}, baseUrl=${context.promotionRedemptionBridgeBaseUrl})`, - ) - } - - // Only mark the recorded promotion as voided after the eWallet void succeeded. - if (context.redeemedPromotionVoidResult) { - context.voidRecordedRedeemedPromotionOutcome(redeemedId, loginId) - } - - return res.status(200).json({ - errorCode: context.redeemedPromotionVoidErrorCode, - errorMsg: context.redeemedPromotionVoidErrorMsg, - result: context.redeemedPromotionVoidResult, - }) - } catch (error) { - log(`[BRIDGE][VOID] eWallet void bridge failed: ${error.message}`) - return res.status(200).json({ - errorCode: context.redeemedPromotionVoidErrorCode || 'EWALLET_VOID_FAILED', - errorMsg: context.redeemedPromotionVoidErrorMsg || error.message, - result: false, - }) - } - }, - ), - ) + return res.status(200).json({ + errorCode: context.redeemedPromotionVoidErrorCode, + errorMsg: context.redeemedPromotionVoidErrorMsg, + result: context.redeemedPromotionVoidResult, + }) + })) } module.exports = registerRedeemedPromotionVoidRoute diff --git a/src/routes/ewl/msgw/contentSend.js b/src/routes/ewl/msgw/contentSend.js new file mode 100644 index 0000000..3c6f012 --- /dev/null +++ b/src/routes/ewl/msgw/contentSend.js @@ -0,0 +1,36 @@ +// Message Gateway 内容发送接口 Mock:POST /ewl/msgw/v1/messages/content/send +// 后端短信已从旧 SMS Hub(/SMSToken + /SMSRequest/Post)切换到该接口,认证复用 EDIP Token。 +// 响应严格按真实网关信封:HTTP 200 + status/errorCode,成功时 smsid 为全小写字段、errorCode 为空字符串。 +function registerMessageGatewayContentSendRoute({ app }) { + app.post('/ewl/msgw/v1/messages/content/send', (req, res) => { + const data = req.body?.data || {} + const recipients = Array.isArray(data.recipients) ? data.recipients : [] + + // 错误注入:MOCK_SMS_FORCE_ERROR_CODE=8923 模拟供应商暂不可用(后端应重试), + // MOCK_SMS_FORCE_ERROR_CODE=8907 模拟号码非法(后端不应重试) + const forcedErrorCode = process.env.MOCK_SMS_FORCE_ERROR_CODE || '' + if (forcedErrorCode) { + return res.status(200).json({ + status: false, + errorCode: forcedErrorCode, + message: process.env.MOCK_SMS_FORCE_ERROR_MSG || 'Mock message gateway error', + data: null, + }) + } + + return res.status(200).json({ + status: true, + errorCode: '', + message: '', + data: { + smsid: `mock-sms-${Date.now()}`, + smsStatus: recipients.map((r) => ({ + recipient: r?.phone || '', + status: 'QUEUED', + })), + }, + }) + }) +} + +module.exports = registerMessageGatewayContentSendRoute diff --git a/src/routes/registerRoutes.js b/src/routes/registerRoutes.js index d3feca1..97dff90 100644 --- a/src/routes/registerRoutes.js +++ b/src/routes/registerRoutes.js @@ -21,6 +21,7 @@ const registerPlayerPromotionFulfillmentJourneyDetailRoute = require('./ewl/bal/ const registerPrizeRedemptionsSyncRoute = require('./api/v1/prizeRedemptionsSync') const registerPromotionRedeemFulfillmentOverridesRoute = require('./mockApi/promotionRedeemFulfillmentOverrides') const registerSmsPortalRoutes = require('./sms/smsPortal') +const registerMessageGatewayContentSendRoute = require('./ewl/msgw/contentSend') function registerRoutes({ app, context, ldapService }) { registerHealthzRoute({ app, ldapService }) @@ -46,6 +47,7 @@ function registerRoutes({ app, context, ldapService }) { registerPrizeRedemptionsSyncRoute({ app, context }) registerPromotionRedeemFulfillmentOverridesRoute({ app, context }) registerSmsPortalRoutes({ app }) + registerMessageGatewayContentSendRoute({ app }) } module.exports = { registerRoutes } diff --git a/src/server.js b/src/server.js index 54b03a7..cd1e4d1 100644 --- a/src/server.js +++ b/src/server.js @@ -9,9 +9,6 @@ const { registerRoutes } = require('./routes/registerRoutes') const app = express() app.use(express.json({ limit: '2mb' })) -const { requestLogger, log } = require('./logger') -app.use(requestLogger) - const context = createMockContext() const ldapService = createMockLdapService({ ldapConfig: context.ldap })