Compare commits

...

14 Commits

Author SHA1 Message Date
irving
9b9b1024c8 fix(deduction): make apply idempotent
Some checks failed
Build and Push Backend / docker (push) Has been cancelled
2026-01-19 00:19:02 -05:00
irving
fffc623ab0 fix(withdraw): tenant filter + enums + tests 2026-01-18 23:58:27 -05:00
irving
6a3b4fef1f test(apitest): add e2e seed endpoints and coverage 2026-01-17 00:49:54 -05:00
irving
e2300fc7d0 feat: earnings deduction batch + test auth hardening 2026-01-16 13:30:04 -05:00
irving
985b35cd90 test: add wechat integration test suite
Some checks failed
Build and Push Backend / docker (push) Has been cancelled
- Add llm/wechat-subsystem-test-matrix.md and tests covering Wx controllers/services\n- Make ApiTestDataSeeder personnel group seeding idempotent for full-suite stability
2026-01-12 18:54:14 -05:00
irving
56239450d4 Add earnings adjustments, withdrawal reject, and auth guard 2026-01-12 12:46:42 -05:00
irving
d335c577d3 fix: restore apitest commodities
Some checks failed
Build and Push Backend / docker (push) Has been cancelled
2026-01-02 02:03:20 -05:00
irving
6fbc28d6f2 fix: harden apitest seeder and pk schedulers 2026-01-02 01:57:41 -05:00
irving
17a8c358a8 feat(pk): implement PK (Player-Killer) system with lifecycle management
- Add PK entity fields: winner, scores, and setting_id
- Implement force start/end API endpoints for clerk PK
- Add PK lifecycle service with auto-start/end scheduling
- Add Redis-based PK state management
- Implement PK detail service with live/history/upcoming queries
- Add WeChat PK controller with history and live PK endpoints
- Add comprehensive PK integration tests
- Create PK setting management with tenant-specific configs
- Add database migrations for PK scores, winner, settings, and menu
- Add PK-related DTOs and enums (status, menu paths)
- Add TenantScope utility for tenant context management
2026-01-02 01:34:03 -05:00
irving
a7e567e9b4 working but not tested 2026-01-01 00:41:55 -05:00
irving
90849e5267 test: stabilize random order cancellation cases
Some checks failed
Build and Push Backend / docker (push) Failing after 12s
2025-12-31 23:12:51 -05:00
irving
ec5c1782c6 fix: allow manager cancellation of random orders 2025-12-31 23:02:43 -05:00
irving
911a974e51 feat: implement order relation type tracking
- Add OrderRelationType enum (UNASSIGNED, LEGACY, FIRST, CONTINUED, NEUTRAL)
- Create play_clerk_customer_relation table to track first-completed history
- Add order_relation_type column to play_order_info
- Migrate existing orders to set relation types based on completion history
- Update order services to determine relation type on creation
- Update order VOs and controllers to expose relation type in API responses
- Update clerk performance calculations to account for relation types
- Update revenue calculations to distinguish between first and continued orders
- Add comprehensive API and unit tests for order relation functionality
2025-12-31 22:06:05 -05:00
irving
f39b560a05 fix: mask random order details for non-owner clerks 2025-12-28 19:31:56 -05:00
199 changed files with 26254 additions and 307 deletions

2861
apitest.out Normal file

File diff suppressed because it is too large Load Diff

2
justfile Normal file
View File

@@ -0,0 +1,2 @@
iperf:
iperf3 -c 101.43.124.74

View File

@@ -0,0 +1,177 @@
# Earnings Adjustments & Withdrawal Reject — Expected Behavior
This document defines the intended behavior for:
- Admin-created **earnings adjustments** (positive or negative earning lines)
- Admin **withdrawal reject** (cancel a withdrawal request and release reserved earning lines)
- **Authorization** rules (permission + group leader scope + cross-tenant isolation)
## Concepts
### Earnings Line
An `earnings line` is an immutable money movement entry for a clerk. Amount can be positive or negative.
### Adjustment
An `adjustment` is an admin-originated earnings line, designed to support future extensibility (many “reasons”, auditability, idempotency, async apply).
Key semantics:
- It **creates exactly one** earnings line when applied.
- The created earnings line uses:
- `earningType = ADJUSTMENT`
- `sourceType = ADJUSTMENT`
- `sourceId = adjustmentId`
- `orderId = null`
- `amount` can be positive or negative
- `unlockTime = effectiveTime` (adjustments are effective at their “unlock” time)
### Withdrawal Reject
Admin reject is a cancel operation that:
- marks the withdrawal request as canceled/rejected
- releases reserved `withdrawing` earnings lines back to `available` / `frozen`
## Authorization Model (New Endpoints)
Authorization is **two-layer**:
1) **Action-level permission**: does the user have permission to call the endpoint?
2) **Object-level scope**: can the user act on the target clerk / request?
### Permission Strings
- Create adjustment: `withdraw:adjustment:create`
- Read/poll adjustment status: `withdraw:adjustment:read`
- Reject withdrawal request: `withdraw:request:reject`
### Group Leader Scope
If the current user is **not** `superAdmin`, they can only act on clerks that belong to a group where:
- `clerk.groupId = group.id`
- `group.sysUserId = currentUserId`
If this scope check fails, the endpoint returns **HTTP 403**.
### Super Admin Bypass
If `superAdmin == true`, the user bypasses permission checks and scope checks for these new endpoints.
### Cross-Tenant Isolation
All operations are tenant-scoped.
- If `X-Tenant` does not match the target entitys `tenantId`, the API returns **HTTP 404** (do not leak existence across tenants).
## Admin Earnings Adjustments API
### Create Adjustment
`POST /admin/earnings/adjustments`
Headers:
- `Idempotency-Key: <uuid>` (required)
- `X-Tenant: <tenantId>` (required)
Body:
```json
{
"clerkId": "clerk-id",
"amount": "20.00",
"reasonType": "MANUAL",
"reasonDescription": "text",
"effectiveTime": "2026-01-01T12:00:00" // optional
}
```
Validation rules:
- `Idempotency-Key` required
- `tenantId` required
- `clerkId` required
- `amount` must be non-zero (positive = reward-like, negative = punishment-like)
- `reasonType` required (currently hard-coded enum values, extend later)
- `reasonDescription` required, non-blank
Idempotency behavior:
- Same `tenantId + Idempotency-Key` with the **same request body** returns the **same** `adjustmentId`.
- Same `tenantId + Idempotency-Key` with a **different request body** returns **HTTP 409**.
Response behavior:
- Always returns **HTTP 202 Accepted** on success (request is “in-progress”).
- Includes `Location: /admin/earnings/adjustments/idempotency/{Idempotency-Key}` for polling.
Response example:
```json
{
"code": 202,
"message": "请求处理中",
"data": {
"adjustmentId": "adj-uuid",
"idempotencyKey": "same-key",
"status": "PROCESSING"
}
}
```
### Poll Adjustment Status
`GET /admin/earnings/adjustments/idempotency/{key}`
Behavior:
- If not found in this tenant: **HTTP 404**
- If found:
- returns **HTTP 200**
- `status` is one of:
- `PROCESSING`: accepted but not yet applied
- `APPLIED`: earnings line has been created
- `FAILED`: apply failed (and should be visible for operator debugging)
Stress / eventual consistency note:
- Under load (DB latency / executor backlog), polling may stay in `PROCESSING` longer, but must not create duplicate earnings lines.
## Withdrawal Reject API
### Reject Withdrawal Request
`POST /admin/withdraw/requests/{id}/reject`
Body:
```json
{ "reason": "text (optional)" }
```
Behavior:
- If request does not exist in this tenant: **HTTP 404**
- If request is already canceled/rejected: return **HTTP 200** (idempotent)
- If request is `success`: return **HTTP 400** (cannot reject a successful payout)
- Otherwise:
- request status transitions to `canceled` (or `rejected` depending on legacy naming)
- all earnings lines with:
- `withdrawalId = requestId`
- `status = withdrawing`
are released:
- `withdrawalId` set to `null`
- if `unlockTime > now` -> `status = frozen`
- else -> `status = available`
## Stats: includeAdjustments toggle
The statistics endpoint supports a toggle `includeAdjustments`:
- when `includeAdjustments = false` (default): only order-derived earnings contribute
- when `includeAdjustments = true`: adjustment earnings lines (`sourceType=ADJUSTMENT`) are included in the revenue sum
Time-window behavior:
- adjustment inclusion is based on `unlockTime` window (equivalent to `effectiveTime`)

View File

@@ -0,0 +1,359 @@
# WeChat Subsystem — Characterization / Integration Test Matrix
This document is a **behavior pin** (characterization) test matrix for the current WeChat-related subsystem.
The goal is to lock observable behavior (HTTP + DB + Redis + external calls) so later refactoring can be done safely.
Repo root: `/Volumes/main/code/yunpei/peipei-backend`
## Source Inventory (entry points)
### Controllers
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPlayController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCommonController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCustomController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOrderInfoController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCouponController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkMediaController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxBlindBoxController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPkController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxGiftController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkCommodityController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxCommodityController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxLevelController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxShopController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkWagesController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPersonnelGroupInfoController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPlayOrderRankingController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawPayeeController.java`
### Services / Cross-cutting
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxOauthService.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomMpService.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxTokenService.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxCustomUserService.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxGiftOrderService.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/WxBlindBoxOrderService.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MediaUploadService.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/security/filter/JwtAuthenticationTokenFilter.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/aspect/CustomUserLoginAspect.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/common/aspect/ClerkUserLoginAspect.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/NotificationSender.java`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/java/com/starry/admin/modules/weichat/service/MockNotificationSender.java`
### DB schema touchpoints
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/resources/db/migration/V1__init_schema.sql`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/main/resources/db/migration/V17__create_media.sql`
Key fields to pin:
- `sys_tenant`: `tenant_key`, `app_id`, `secret`, `mch_id`, `mch_key`, `*_template_id`, `profitsharing_rate`
- `play_custom_user_info`: `openid`, `unionid`, `wei_chat_code`, `token`
- `play_clerk_user_info`: `openid` (NOT NULL), `wei_chat_code`, `token`, `online_state`, `onboarding_state`, `listing_state`, `clerk_state`
- `play_order_info`: `order_type`, `pay_method`, `pay_state`, `place_type`, `reward_type`, `wei_chat_code`, `profit_sharing_amount`
- `play_media` / `play_clerk_media_asset`: `status`, `usage`, `review_state`, `deleted`, `order_index` + unique constraint `uk_clerk_usage_media`
## Test Harness Guidelines
### Required runtime surfaces to observe
1) **HTTP**: status code + response schema (normalized) for every `/wx/**` route.
2) **DB**: before/after snapshots of rows touched by the route (focus tables above).
3) **Redis**: keys under:
- `TENANT_INFO:*`
- `login_codes:*`
- PK keys under `PkRedisKeyConstants` (if enabled)
4) **External calls**: record interactions with:
- `WxMpService` / WeChat MP template message service
- `WxPayService` (unified order + profitsharing)
- `IOssFileService`
- `SmsUtils`
- background tasks like `OverdueOrderHandlerTask`
### Profiles
- Prefer running integration tests under `apitest` profile (seeded DB + mock notifier).
- For tests that must hit real async behavior, use deterministic executors in test config (or replace `ThreadPoolTaskExecutor` with inline executor via `@MockBean`).
### Normalization rules (avoid flaky tests)
- Normalize dynamic fields: timestamps, random IDs (UUID), and generated nonces.
- For money: always compare as `BigDecimal` with explicit scale/rounding.
- For lists: assert stable ordering only when the endpoint guarantees ordering.
---
## 1) Gateway / Tenant / AuthN (Filter + AOP)
| ID | Surface | Route / Component | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| GW-001 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/**` without `tenantkey` header on a login-required route | none | none | response is error (pin: code/body shape + status) |
| GW-002 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/**` without `tenantkey` on a no-login whitelist route | none | none | response is error (pin: JSON body written by filter) |
| GW-003 | HTTP | `JwtAuthenticationTokenFilter` | `/wx/pay/jsCallback` bypasses auth and does not require `tenantkey` | none | none | handler executes and returns success string |
| GW-004 | HTTP/Redis | filter `getTenantId` | with `tenantkey` only (no token) | tenant exists | `ISysTenantService` real or stub | `SecurityUtils.getTenantId()` set; request proceeds |
| GW-005 | HTTP/Redis | filter `getTenantId` | with clerk token | seed clerk with token + tenantId | none | tenantId resolved from DB; `TENANT_INFO:{userId}` read does not break flow |
| GW-006 | HTTP/Redis | filter `getTenantId` | with custom token | seed customer with token + tenantId | none | tenantId resolved from DB |
| AOP-001 | HTTP | `@CustomUserLogin` endpoints | missing `customusertoken` header | none | none | 401 `token为空` |
| AOP-002 | HTTP | `@ClerkUserLogin` endpoints | missing `clerkusertoken` header | none | none | 401 `token为空` |
| AOP-003 | HTTP | `CustomUserLoginAspect` | invalid token format/signature | none | none | 401 `获取用户信息异常` |
| AOP-004 | HTTP | `CustomUserLoginAspect` | token ok but DB token mismatch | seed user with different token | none | 401 `token异常` |
| AOP-005 | HTTP | `ClerkUserLoginAspect` | `ensureClerkSessionIsValid` rejects | seed clerk state invalid | none | 401 message matches current `CustomException` message |
| TOK-001 | unit/contract | `WxTokenService` | token generated and parsed (with/without `Bearer `) | config secret + expireTime | none | same userId extracted |
| TOK-002 | unit/contract | `WxTokenService` | expired token fails | short expireTime | none | parsing throws; upstream maps to 401 |
---
## 2) WeChat OAuth (WxOauthController + WxOauthService)
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| OAUTH-001 | HTTP | `POST /wx/oauth2/getConfigAddress` | `url` omitted -> uses default callback | valid tenant | mock `WxMpService` signature | signature returned; default URL pinned |
| OAUTH-002 | HTTP | `POST /wx/oauth2/getConfigAddress` | `url` provided -> overrides default | valid tenant | mock signature | uses provided URL |
| OAUTH-003 | HTTP | `POST /wx/oauth2/getClerkLoginAddress` | returns auth URL with scope `snsapi_userinfo` | valid tenant | mock OAuth2 service build URL | response contains expected scope |
| OAUTH-004 | HTTP | `POST /wx/oauth2/getCustomLoginAddress` | same as clerk | valid tenant | mock OAuth2 service | response contains expected scope |
| OAUTH-005 | HTTP/DB/Redis | `POST /wx/oauth2/custom/login` | success -> returns token payload + persists token | seed tenant + seeded customer openid | mock `WxMpService.getOAuth2Service().getAccessToken/getUserInfo` | response includes `tokenValue/tokenName/loginDate`; DB token updated; Redis `TENANT_INFO:{id}` set |
| OAUTH-006 | HTTP | `POST /wx/oauth2/custom/login` | upstream WeChat oauth fails -> unauthorized | none | mock `WxMpService.getOAuth2Service().getAccessToken` to throw | response `code=401` (and `success=false`) |
| OAUTH-007 | HTTP/DB/Redis | `POST /wx/oauth2/clerk/login` | success -> token persisted + tenant cached; `pcData` present | seed a clerk with `sysUserId=""` to avoid PC login dependency | mock `WxMpService.getOAuth2Service().getAccessToken/getUserInfo` | response includes `pcData.token/role` (empty strings); DB token updated; Redis `TENANT_INFO:{id}` set |
| OAUTH-008 | HTTP/DB | `GET /wx/oauth2/custom/logout` | token invalidated | seed user + token | none | DB token set to `empty`; subsequent access 401 |
| OAUTH-009 | service/DB | `WxOauthService.customUserLogin` | first login creates new user with registrationTime | empty DB | mock MP OAuth2 | row inserted with expected fields |
| OAUTH-010 | service/DB | `WxOauthService.clerkUserLogin` | deleted clerk restored | seed clerk deleted=true | mock MP OAuth2 | deleted=false; default states filled; token/online reset |
| OAUTH-011 | HTTP | `GET /wx/oauth2/checkSubscribe` | returns boolean subscribe state | logged-in customer | mock `WxMpService.getUserService().userInfo` | response `data` is `true/false` |
---
## 3) WeChat Pay Recharge + Callback + Profit Sharing (WxPlayController)
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| PAY-001 | HTTP | `GET /wx/pay/custom/getCustomPaymentAmount` | money empty -> 500 | logged-in customer | none | error message pinned |
| PAY-002 | HTTP | `GET /wx/pay/custom/getCustomPaymentAmount` | money valid -> BigDecimal returned | logged-in customer | none | response number pinned (scale/rounding) |
| PAY-003 | HTTP/DB | `GET /wx/pay/custom/createOrder` | money < 1 -> 500 | logged-in customer | none | error message pinned |
| PAY-004 | HTTP/DB | `GET /wx/pay/custom/createOrder` | creates recharge order + returns JSAPI pay params | logged-in customer + tenant with mch config | mock `WxPayService.unifiedOrder` and `SignUtils` inputs via capturing request | order row created (type/pay_state); response has required fields; unifiedOrder request fields pinned |
| PAY-005 | HTTP | `GET /wx/pay/custom/createOrder` | subscribe check fails -> error | customer openid + tenant | mock `WxCustomMpService.checkSubscribeThrowsExp` to throw | HTTP error pinned |
| PAY-006 | HTTP/DB | `POST/GET /wx/pay/jsCallback` | invalid XML -> still returns success string, no DB changes | existing DB | none | response equals success; no order updates |
| PAY-007 | HTTP/DB | `/wx/pay/jsCallback` | unknown out_trade_no -> no changes | none | none | no DB changes |
| PAY-008 | HTTP/DB | `/wx/pay/jsCallback` | order_type!=0 OR pay_state!=0 -> no reprocessing | seed paid/non-recharge order | none | no balance change; no state change |
| PAY-009 | HTTP/DB/Redis | `/wx/pay/jsCallback` | happy path updates pay_state and balance | seed recharge order pay_state=0 + tenant attach | mock `customAccountBalanceRecharge` if needed or assert real side-effects | pay_state becomes `1`; balance updated; template message called |
| PAY-010 | HTTP/DB | `/wx/pay/jsCallback` | replay same callback twice is idempotent | seed recharge order | none | balance does not double-add; profit_sharing_amount not duplicated |
| PAY-011 | HTTP/DB | profitSharing | profitsharing_rate<=0 -> no call and no write | tenant rate=0 | mock WxPay profitSharing service | no API call; no DB update |
| PAY-012 | HTTP/DB | profitSharing | rate>0 and computed amount=0 -> no call | tiny amount | mock | no call |
| PAY-013 | HTTP/DB | profitSharing | rate>0 -> writes profit_sharing_amount | tenant rate set | mock profitSharing result | DB field set to expected BigDecimal |
---
## 4) WeChat MP Notifications (WxCustomMpService)
| ID | Surface | Component | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| MP-001 | unit/contract | `proxyWxMpService` | missing tenantId -> CustomException | tenantId unset | none | exception message pinned |
| MP-002 | unit/contract | `getWxPay` | tenant missing mch_id -> CustomException | tenant has empty mchId | none | message pinned |
| MP-003 | integration | `sendCreateOrderMessage` | template data fields mapping | tenant has templateId + tenantKey | mock template msg service | `short_thing5` label resolved; URL pinned |
| MP-004 | integration | `sendCreateOrderMessageBatch` | filters offboarded/delisted clerks | clerk list mix | deterministic executor + mock template | only eligible clerks called |
| MP-005 | integration | `sendBalanceMessage` | sends recharge success template | order + tenant + customer | mock template | data keys pinned |
| MP-006 | integration | `sendOrderFinishMessage` | only placeType "1"/"2" triggers | orders with other placeType | mock template | no calls when not matched |
| MP-007 | integration | async wrappers | exceptions inside async do not bubble | throw in underlying send | deterministic executor | caller does not fail |
| MP-008 | integration | subscribe checks | subscribe=false -> ServiceException message pinned | mock `WxMpUser.subscribe=false` | mock user service | message pinned |
---
## 5) Common WeChat Tools (WxCommonController + WxFileUtils)
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| COM-001 | HTTP | `GET /wx/common/area/tree` | returns area tree | tenantKey only | mock area service or real | response schema pinned |
| COM-002 | HTTP | `GET /wx/common/setting/info` | returns global UI config (NOT tenant-scoped) | call with two different `X-Tenant` values | real service | both responses share same `data.id` |
| COM-003 | HTTP | `POST /wx/common/file/upload` | uploads to OSS returns URL | multipart file | mock `IOssFileService.upload` | returned URL pinned |
| COM-004 | HTTP | `GET /wx/common/audio/upload` | mediaId empty -> error | none | none | error message pinned |
| COM-005 | HTTP | `GET /wx/common/audio/upload` | successful path uploads mp3 | provide accessToken + temp audio stream | mock `WxAccessTokenService`, stub `WxFileUtils.getTemporaryMaterial` (via wrapper or test seam), mock OSS | returns URL; temp files cleaned |
| COM-006 | unit/contract | `WxFileUtils.audioConvert2Mp3` | invalid source -> throws | empty file | none | error message pinned |
---
## 6) Customer (WxCustomController) — Orders, Gifts, Complaints, Follow, Leave Msg
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| CUS-001 | HTTP | `GET /wx/custom/queryClerkDetailedById` | not logged in still works | tenantKey only | none | response pinned (no crash) |
| CUS-002 | HTTP/DB | `GET /wx/custom/queryById` | returns clerkState=1 when openid matches clerk | seed custom+clerk share openid | none | response `clerkState` pinned |
| CUS-003 | HTTP/DB | `POST /wx/custom/updateHideLevelState` | ignores client id and uses session id | logged-in custom | none | DB update on session row only |
| CUS-004 | HTTP/DB | `POST /wx/custom/order/reward` | creates completed reward order | logged-in custom + balance | none | order row fields pinned; ledger/balance delta pinned |
| CUS-005 | HTTP/DB | `POST /wx/custom/order/gift` | calls WxGiftOrderService and increments gift counts | logged-in custom + gift seeded | none | order created; both gift counters incremented |
| CUS-006 | HTTP/DB | `POST /wx/custom/order/commodity` | creates specified order pending | logged-in custom + clerk + commodity | mock pricing if needed | order fields pinned |
| CUS-007 | HTTP/DB | `POST /wx/custom/order/random` | success triggers notification + overdue task | logged-in custom + clerks eligible | mock `WxCustomMpService`, mock `OverdueOrderHandlerTask` | expected calls + order fields pinned |
| CUS-008 | HTTP/DB | `POST /wx/custom/order/random` | insufficient balance fails and no order created | set balance=0 | none | error code pinned; no DB insert |
| CUS-009 | HTTP | `POST /wx/custom/order/queryByPage` | only returns self purchaserBy | seed orders for multiple users | none | result contains only self |
| CUS-010 | HTTP/DB | `GET /wx/custom/order/end` | state transition invoked | seed order | none | order status moved (pin) |
| CUS-011 | HTTP/DB | `POST /wx/custom/order/cancellation` | pins cancellation refund record (images ignored) | seed pending/accepted order + send non-empty images | none | order canceled; refund record has `refundReason`; `images` stays null; order `refundReason` stays null |
| CUS-012 | HTTP/DB | `POST /wx/custom/order/evaluate/add` | non-purchaser cannot evaluate | order purchaser different | none | error message pinned |
| CUS-013 | HTTP | `GET /wx/custom/order/evaluate/queryByOrderId` | not evaluated -> error | seed order no eval | none | `当前订单未评价` |
| CUS-014 | HTTP/DB | `POST /wx/custom/order/complaint/add` | non-purchaser cannot complain | order purchaser different | none | error message pinned |
| CUS-015 | HTTP/DB | `POST /wx/custom/leave/add` | creates leave msg; response message pinned | logged-in custom | none | DB insert; response message currently `"取消成功"` |
| CUS-016 | HTTP | `GET /wx/custom/leave/queryPermission` | permission true/false schema pinned | set conditions | none | response JSON has `permission` boolean and `msg` |
| CUS-017 | HTTP/DB | `POST /wx/custom/followState/update` | idempotency and correctness | seed follow relation | none | follow state pinned |
| CUS-018 | HTTP | `POST /wx/custom/follow/queryByPage` | paging/filters pinned | seed relations | none | page schema pinned |
---
## 7) Clerk (WxClerkController) — Apply, Profile Review, Order Ops, Privacy Rules
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| CLK-001 | HTTP | `POST /wx/clerk/user/queryPerformanceInfo` | date normalize behavior pinned | seed orders | none | output stable for same input |
| CLK-002 | HTTP | `GET /wx/clerk/user/queryLevelInfo` | hardcoded `levelAndRanking` list pinned | logged-in clerk | none | list size/content pinned |
| CLK-003 | HTTP/Redis | `POST /wx/clerk/user/sendCode` | writes redis key with TTL and returns code | logged-in clerk | none | redis key format + TTL; response contains code |
| CLK-004 | HTTP/Redis/DB | `POST /wx/clerk/user/bindCode` | wrong code -> error | seed redis code | none | `验证码错误` |
| CLK-005 | HTTP/Redis/DB | `POST /wx/clerk/user/bindCode` | success updates phone and clears redis | seed redis code | none | DB phone updated; redis deleted |
| CLK-006 | HTTP/DB | `POST /wx/clerk/user/add` | already clerk -> error | seed clerkState=1 | none | message pinned |
| CLK-007 | HTTP/DB | `POST /wx/clerk/user/add` | already has pending review -> error | seed reviewState=0 | none | message pinned |
| CLK-008 | HTTP | `POST /wx/clerk/user/add` | subscribe required | mock subscribe=false | mock `WxCustomMpService.checkSubscribeThrowsExp` | error message pinned |
| CLK-009 | HTTP/DB | `POST /wx/clerk/user/updateNickname` | creates data review row with correct type and content | logged-in clerk | none | DB insert pinned |
| CLK-010 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | empty album -> error | logged-in clerk | none | `最少上传一张照片` |
| CLK-011 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | invalid new media -> error | seed play_media with mismatched owner/tenant/status | none | error message pinned |
| CLK-012 | HTTP/DB | `POST /wx/clerk/user/updateAlbum` | legacy IDs not found -> should not fail (current behavior) | album includes missing IDs | none | request succeeds; review content contains legacy strings |
| CLK-013 | HTTP/DB | `GET /wx/clerk/order/queryById` | privacy: non-owner clears weiChatCode | seed order acceptBy other clerk | none | weiChatCode empty |
| CLK-014 | HTTP/DB | `GET /wx/clerk/order/queryById` | canceled order clears weiChatCode | seed orderStatus=4 | none | weiChatCode empty |
| CLK-015 | HTTP | `GET /wx/clerk/order/accept` | subscribe required | mock subscribe=false | mock `WxCustomMpService` | fails |
| CLK-016 | HTTP/DB | `GET /wx/clerk/order/start` | state transition pinned | seed order | none | state updated |
| CLK-017 | HTTP/DB | `POST /wx/clerk/order/complete` | sysUserId missing -> error | seed clerk no sysUserId | none | message pinned |
| CLK-018 | HTTP/DB | `POST /wx/clerk/order/complete` | permission resolution pinned (admin vs group leader) | seed sysUser mapping | none | correct operatorType chosen or error |
| CLK-019 | HTTP/DB | `POST /wx/clerk/order/cancellation` | cancellation state pinned | seed order | none | status/cancel fields updated |
| CLK-020 | HTTP | `POST /wx/clerk/user/queryEvaluateByPage` | forces hidden=VISIBLE | seed hidden evaluations | none | response excludes hidden |
---
## 8) Orders (WxOrderInfoController) — Continuation + Random Order Masking
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| ORD-001 | HTTP/DB | `POST /wx/order/clerk/continue` | non-owner cannot continue | order acceptBy != clerk | none | message pinned |
| ORD-002 | HTTP/DB | `POST /wx/order/clerk/continue` | second continuation blocked | seed continue record | none | message pinned |
| ORD-003 | HTTP | `GET /wx/order/clerk/selectRandomOrderById` | masking for non-owner pinned | seed random order accepted by other | none | fields blanked as implemented |
| ORD-004 | HTTP/DB | `POST /wx/order/custom/updateReviewState` | reviewedState != 0 -> error | seed reviewedState=1 | none | `续单已处理` |
| ORD-005 | HTTP/DB | `POST /wx/order/custom/continueListByPage` | customId forced to session | seed multiple users | none | only self results |
---
## 9) Coupons (WxCouponController)
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| CP-001 | HTTP | `GET /wx/coupon/custom/obtainCoupon` | id empty -> error message pinned | logged-in custom | none | error message pinned |
| CP-002 | HTTP/DB | obtain coupon | not eligible -> returns reason | seed coupon restrictions | none | response contains reason |
| CP-003 | HTTP/DB | obtain coupon | eligible -> claim succeeds | seed coupon inventory | none | coupon_details inserted; response success |
| CP-004 | HTTP | query all | whitelist hides coupons from non-whitelisted | seed coupon whitelist | none | coupon not present |
| CP-005 | HTTP | query by order | clerkId+levelId both empty -> error | none | none | message pinned |
| CP-006 | HTTP/DB | query by order | unavailable coupon includes reasonForUnavailableUse | seed coupon restrictions | none | available=0 and reason set |
| CP-007 | HTTP/DB | query by order | exceptions inside loop are swallowed (current behavior) | seed one broken coupon | none | endpoint still returns 200 with remaining coupons |
---
## 10) Media (WxClerkMediaController + MediaUploadService)
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| MED-001 | HTTP | `POST /wx/clerk/media/upload` | missing file -> error | logged-in clerk | none | message pinned |
| MED-002 | HTTP/DB | upload image | creates `play_media` + `play_clerk_media_asset` | image multipart | mock OSS | DB rows inserted; kind=image; owner=clerk |
| MED-003 | HTTP/DB | upload video too large | exceeds 30MB -> error | video > limit | none | error message pinned |
| MED-004 | HTTP/DB | upload video too long | duration>45s -> error | long video | none | message pinned |
| MED-005 | HTTP/DB | `PUT /wx/clerk/media/order` | distinct mediaIds and ordering pinned | seed assets | none | order_index updates pinned; duplicates removed |
| MED-006 | HTTP/DB | `DELETE /wx/clerk/media/{id}` | marks `review_state=rejected` and sets `play_media.status=rejected` (asset `deleted` stays `0` currently) | seed media + asset | none | asset `review_state` becomes rejected; `play_media.status` becomes rejected; `asset.deleted` remains `0` |
| MED-007 | HTTP | `GET /wx/clerk/media/list` | returns only draft/pending/rejected | seed assets states | none | filtering pinned |
| MED-008 | HTTP | `GET /wx/clerk/media/approved` | returns only approved | seed assets | none | filtering pinned |
| MED-009 | DB constraint | `uk_clerk_usage_media` | duplicate submit behavior pinned | seed duplicate row | none | error or ignore (pin current) |
---
## 11) Blind Box (WxBlindBoxController + WxBlindBoxOrderService)
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| BB-001 | HTTP | `GET /wx/blind-box/config/list` | not logged in -> error | none | none | message pinned |
| BB-002 | HTTP | list configs | only active configs for tenant | seed configs | none | list content pinned |
| BB-003 | HTTP/DB | `POST /wx/blind-box/order/purchase` | tenant mismatch -> “not found” (TenantLine current behavior) | config tenant != user tenant | none | message pinned (`盲盒不存在`) |
| BB-004 | HTTP/DB | purchase | creates completed order + reward | seed config + balance | none | order type pinned; reward row created |
| BB-005 | HTTP/DB | `POST /wx/blind-box/reward/{id}/dispatch` | reward not found -> error | none | none | message pinned |
| BB-006 | HTTP/DB | dispatch | status transition pinned | seed reward | none | status updated; response view pinned |
| BB-007 | HTTP | `GET /wx/blind-box/reward/list` | status filter pinned | seed rewards | none | filter results pinned |
| BB-008 | HTTP/DB | `POST /wx/blind-box/order/purchase` | insufficient balance -> error and no new order | customer balance < config price | none | message pinned; no new order inserted |
---
## 12) PK (WxPkController)
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| PK-001 | HTTP | `GET /wx/pk/clerk/live` | clerkId missing -> error | none | none | message pinned |
| PK-002 | HTTP | live | no pk -> inactive dto | seed none | none | returns inactive dto |
| PK-003 | HTTP | live | pk exists but status!=IN_PROGRESS -> inactive | seed pk | none | inactive |
| PK-004 | HTTP/Redis | upcoming | tenant missing -> error | no tenant context | none | message pinned |
| PK-005 | HTTP/Redis | upcoming | redis hit/miss produces stable behavior | seed redis keys | none | response pinned |
| PK-006 | HTTP | schedule/history | limit/page normalization pinned | none | none | safeLimit behavior pinned |
---
## 13) Shop + Articles + Misc
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| SHOP-001 | HTTP | `GET /wx/shop/custom/getShopHomeCarouseInfo` | returns carousel list | seed carousel | none | mapping pinned |
| SHOP-002 | HTTP/DB | `GET /wx/shop/clerk/readShopArticleInfo` | visitsNumber increments | seed article | none | visitsNumber +1 persisted |
| ART-001 | HTTP/DB | `POST /wx/article/clerk/add` | creates article with clerkId from session | logged-in clerk | none | DB insert pinned |
| ART-002 | HTTP/DB | `GET /wx/article/clerk/deleteById` | deletes clerk article + custom article links | seed both | none | rows removed/soft-deleted pinned |
| ART-003 | HTTP/DB | `POST /wx/article/custom/updateGreedState` | pins current toggle behavior (not strictly idempotent) | seed article | none | at least one row flips to `endorseState=0` after toggle |
| ART-004 | HTTP/DB | `POST /wx/article/custom/updateFollowState` | same for follow | seed article | none | record updated |
---
## 14) Wages + Withdraw (WxClerkWagesController + WxWithdrawController)
| ID | Surface | Route | Scenario | Setup | Mocks | Assertions |
|---:|---|---|---|---|---|---|
| WAGE-001 | HTTP/DB | `GET /wx/wages/clerk/queryUnsettledWages` | sums orders correctly | seed settlement_state=0 orders | none | totals pinned |
| WAGE-002 | HTTP | `GET /wx/wages/clerk/queryCurrentPeriodWages` | missing wages row returns constructed zeros | no wages row | none | response has zeros and dates set |
| WAGE-003 | HTTP | `POST /wx/wages/clerk/queryHistoricalWages` | current hard-coded page meta pinned | seed some rows | none | total=5,size=10,pages=1 pinned |
| WD-001 | HTTP | `GET /wx/withdraw/balance` | returns available/pending/nextUnlock | seed earnings lines | none | values pinned |
| WD-002 | HTTP | `GET /wx/withdraw/earnings` | time parsing supports multiple formats | seed earnings | none | filter correctness pinned |
| WD-003 | HTTP/DB | `POST /wx/withdraw/requests` | amount<=0 error | none | none | message pinned |
| WD-004 | HTTP/DB | create request | creates withdrawal request and reserves lines | seed available lines | none | statuses pinned |
| WD-005 | HTTP | request logs | non-owner forbidden | seed request other clerk | none | `无权查看` |
| PAYEE-001 | HTTP | `GET /wx/withdraw/payee` | no profile returns null data (current behavior) | none | none | response pinned |
| PAYEE-002 | HTTP/DB | `POST /wx/withdraw/payee` | missing qrCodeUrl -> error | none | none | message pinned |
| PAYEE-003 | HTTP/DB | upsert defaulting | defaults channel/displayName | seed clerk | none | stored defaults pinned |
| PAYEE-004 | HTTP/DB | confirm requires qrCodeUrl | no profile | none | error message pinned |
---
## Coverage Checklist (for “done”)
- Every `@RequestMapping("/wx...")` route has at least:
- 1 happy-path integration test
- 1 auth/tenant gating test (where applicable)
- 1 validation failure test (where applicable)
- 1 DB side-effect snapshot assertion for routes that mutate state
- WeChat Pay callback has:
- replay/idempotency tests
- malformed XML test
- missing attach test
- Media pipeline has:
- type detection + size/duration limits pinned
- DB uniqueness behavior pinned
## Test Case ID Naming Convention (important)
- In this doc, case IDs are written like `OAUTH-007`.
- In Java test method names, we normalize them as `OAUTH_007` because `-` is not valid in Java identifiers.
## Implemented Coverage (current)
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxOauthControllerApiTest.java`: `OAUTH-001..008`, `OAUTH-011`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxOauthServiceTest.java`: `OAUTH-009`, `OAUTH-010`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxPayControllerApiTest.java`: `PAY-001..011`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxAuthAspectApiTest.java`: `AOP-001..005`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxTokenServiceTest.java`: `TOK-001..002`
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxClerkMediaControllerEndpointsApiTest.java`: `MED-005..008` (note: `MED-006` currently keeps `asset.deleted=0`)
- `/Volumes/main/code/yunpei/peipei-backend/play-admin/src/test/java/com/starry/admin/api/WxCommonControllerAudioUploadApiTest.java`: covers audio upload behavior (see `MED-*` section for alignment)

View File

@@ -25,6 +25,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Flyway --> <!-- Flyway -->
<dependency> <dependency>
<groupId>org.flywaydb</groupId> <groupId>org.flywaydb</groupId>
@@ -159,6 +163,11 @@
<artifactId>mockito-junit-jupiter</artifactId> <artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework</groupId> <groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId> <artifactId>spring-test</artifactId>

View File

@@ -1,9 +1,13 @@
package com.starry.admin.common.apitest; package com.starry.admin.common.apitest;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.modules.clerk.mapper.PlayClerkUserInfoMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkCommodityEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.enums.ClerkRoleStatus;
import com.starry.admin.modules.clerk.module.enums.ListingStatus;
import com.starry.admin.modules.clerk.module.enums.OnboardingStatus;
import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService; import com.starry.admin.modules.clerk.service.IPlayClerkCommodityService;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
@@ -15,6 +19,7 @@ import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
import com.starry.admin.modules.shop.mapper.PlayClerkGiftInfoMapper; import com.starry.admin.modules.shop.mapper.PlayClerkGiftInfoMapper;
import com.starry.admin.modules.shop.mapper.PlayCommodityInfoMapper;
import com.starry.admin.modules.shop.module.entity.PlayClerkGiftInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayClerkGiftInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity;
import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity;
@@ -23,6 +28,9 @@ import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService; import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService;
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService; import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
import com.starry.admin.modules.shop.service.IPlayGiftInfoService; import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
import com.starry.admin.modules.system.mapper.SysMenuMapper;
import com.starry.admin.modules.system.mapper.SysUserMapper;
import com.starry.admin.modules.system.module.entity.SysMenuEntity;
import com.starry.admin.modules.system.module.entity.SysTenantEntity; import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity; import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity;
import com.starry.admin.modules.system.module.entity.SysUserEntity; import com.starry.admin.modules.system.module.entity.SysUserEntity;
@@ -31,16 +39,19 @@ import com.starry.admin.modules.system.service.ISysTenantService;
import com.starry.admin.modules.system.service.SysUserService; import com.starry.admin.modules.system.service.SysUserService;
import com.starry.admin.modules.weichat.service.WxTokenService; import com.starry.admin.modules.weichat.service.WxTokenService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.UserConstants;
import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Date; import java.util.Date;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -66,9 +77,11 @@ public class ApiTestDataSeeder implements CommandLineRunner {
public static final String DEFAULT_COMMODITY_ID = "svc-basic"; public static final String DEFAULT_COMMODITY_ID = "svc-basic";
public static final String DEFAULT_CLERK_COMMODITY_ID = "clerk-svc-basic"; public static final String DEFAULT_CLERK_COMMODITY_ID = "clerk-svc-basic";
public static final String DEFAULT_CUSTOMER_ID = "customer-apitest"; public static final String DEFAULT_CUSTOMER_ID = "customer-apitest";
public static final String DEFAULT_CUSTOMER_OPEN_ID = "openid-customer-apitest";
public static final String DEFAULT_GIFT_ID = "gift-basic"; public static final String DEFAULT_GIFT_ID = "gift-basic";
public static final String DEFAULT_GIFT_NAME = "API测试礼物"; public static final String DEFAULT_GIFT_NAME = "API测试礼物";
public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00"); public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00");
public static final BigDecimal E2E_CUSTOMER_BALANCE = new BigDecimal("1000.00");
private static final String GIFT_TYPE_REGULAR = "1"; private static final String GIFT_TYPE_REGULAR = "1";
private static final String GIFT_STATE_ACTIVE = "0"; private static final String GIFT_STATE_ACTIVE = "0";
private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00"); private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00");
@@ -77,6 +90,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
private final ISysTenantPackageService tenantPackageService; private final ISysTenantPackageService tenantPackageService;
private final ISysTenantService tenantService; private final ISysTenantService tenantService;
private final SysUserService sysUserService; private final SysUserService sysUserService;
private final SysUserMapper sysUserMapper;
private final SysMenuMapper sysMenuMapper;
private final IPlayPersonnelGroupInfoService personnelGroupInfoService; private final IPlayPersonnelGroupInfoService personnelGroupInfoService;
private final IPlayClerkLevelInfoService clerkLevelInfoService; private final IPlayClerkLevelInfoService clerkLevelInfoService;
private final IPlayClerkUserInfoService clerkUserInfoService; private final IPlayClerkUserInfoService clerkUserInfoService;
@@ -84,6 +99,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
private final IPlayCommodityAndLevelInfoService commodityAndLevelInfoService; private final IPlayCommodityAndLevelInfoService commodityAndLevelInfoService;
private final IPlayGiftInfoService giftInfoService; private final IPlayGiftInfoService giftInfoService;
private final IPlayClerkCommodityService clerkCommodityService; private final IPlayClerkCommodityService clerkCommodityService;
private final PlayClerkUserInfoMapper clerkUserInfoMapper;
private final PlayCommodityInfoMapper commodityInfoMapper;
private final IPlayClerkGiftInfoService playClerkGiftInfoService; private final IPlayClerkGiftInfoService playClerkGiftInfoService;
private final IPlayCustomUserInfoService customUserInfoService; private final IPlayCustomUserInfoService customUserInfoService;
private final IPlayCustomGiftInfoService playCustomGiftInfoService; private final IPlayCustomGiftInfoService playCustomGiftInfoService;
@@ -96,6 +113,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
ISysTenantPackageService tenantPackageService, ISysTenantPackageService tenantPackageService,
ISysTenantService tenantService, ISysTenantService tenantService,
SysUserService sysUserService, SysUserService sysUserService,
SysUserMapper sysUserMapper,
SysMenuMapper sysMenuMapper,
IPlayPersonnelGroupInfoService personnelGroupInfoService, IPlayPersonnelGroupInfoService personnelGroupInfoService,
IPlayClerkLevelInfoService clerkLevelInfoService, IPlayClerkLevelInfoService clerkLevelInfoService,
IPlayClerkUserInfoService clerkUserInfoService, IPlayClerkUserInfoService clerkUserInfoService,
@@ -103,6 +122,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
IPlayCommodityAndLevelInfoService commodityAndLevelInfoService, IPlayCommodityAndLevelInfoService commodityAndLevelInfoService,
IPlayGiftInfoService giftInfoService, IPlayGiftInfoService giftInfoService,
IPlayClerkCommodityService clerkCommodityService, IPlayClerkCommodityService clerkCommodityService,
PlayClerkUserInfoMapper clerkUserInfoMapper,
PlayCommodityInfoMapper commodityInfoMapper,
IPlayClerkGiftInfoService playClerkGiftInfoService, IPlayClerkGiftInfoService playClerkGiftInfoService,
IPlayCustomUserInfoService customUserInfoService, IPlayCustomUserInfoService customUserInfoService,
IPlayCustomGiftInfoService playCustomGiftInfoService, IPlayCustomGiftInfoService playCustomGiftInfoService,
@@ -113,6 +134,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
this.tenantPackageService = tenantPackageService; this.tenantPackageService = tenantPackageService;
this.tenantService = tenantService; this.tenantService = tenantService;
this.sysUserService = sysUserService; this.sysUserService = sysUserService;
this.sysUserMapper = sysUserMapper;
this.sysMenuMapper = sysMenuMapper;
this.personnelGroupInfoService = personnelGroupInfoService; this.personnelGroupInfoService = personnelGroupInfoService;
this.clerkLevelInfoService = clerkLevelInfoService; this.clerkLevelInfoService = clerkLevelInfoService;
this.clerkUserInfoService = clerkUserInfoService; this.clerkUserInfoService = clerkUserInfoService;
@@ -120,6 +143,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
this.commodityAndLevelInfoService = commodityAndLevelInfoService; this.commodityAndLevelInfoService = commodityAndLevelInfoService;
this.giftInfoService = giftInfoService; this.giftInfoService = giftInfoService;
this.clerkCommodityService = clerkCommodityService; this.clerkCommodityService = clerkCommodityService;
this.clerkUserInfoMapper = clerkUserInfoMapper;
this.commodityInfoMapper = commodityInfoMapper;
this.playClerkGiftInfoService = playClerkGiftInfoService; this.playClerkGiftInfoService = playClerkGiftInfoService;
this.customUserInfoService = customUserInfoService; this.customUserInfoService = customUserInfoService;
this.playCustomGiftInfoService = playCustomGiftInfoService; this.playCustomGiftInfoService = playCustomGiftInfoService;
@@ -132,6 +157,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
@Override @Override
@Transactional @Transactional
public void run(String... args) { public void run(String... args) {
seedPcTenantWagesMenu();
seedTenantPackage(); seedTenantPackage();
seedTenant(); seedTenant();
@@ -156,6 +182,98 @@ public class ApiTestDataSeeder implements CommandLineRunner {
} }
} }
private void seedPcTenantWagesMenu() {
// Minimal menu tree for pc-tenant E2E: /play/clerk/wages -> play/clerk/wages/index.vue
// This is apitest-only; prod/dev menus are managed by ops/admin tooling.
SysMenuEntity playRoot = ensureMenu(
"陪聊管理",
"PlayManage",
0L,
UserConstants.TYPE_DIR,
"/play",
UserConstants.LAYOUT,
50);
SysMenuEntity clerkDir = ensureMenu(
"店员管理",
"ClerkManage",
playRoot.getMenuId(),
UserConstants.TYPE_DIR,
"clerk",
"",
1);
ensureMenu(
"收益管理",
"ClerkWages",
clerkDir.getMenuId(),
UserConstants.TYPE_MENU,
"wages",
"play/clerk/wages/index",
1);
}
private SysMenuEntity ensureMenu(
String menuName,
String menuCode,
Long parentId,
String menuType,
String path,
String component,
Integer sort) {
Optional<SysMenuEntity> existing = sysMenuMapper.selectList(Wrappers.<SysMenuEntity>lambdaQuery()
.eq(SysMenuEntity::getDeleted, false)
.eq(SysMenuEntity::getParentId, parentId)
.eq(SysMenuEntity::getMenuCode, menuCode)
.last("limit 1"))
.stream()
.findFirst();
if (existing.isPresent()) {
SysMenuEntity current = existing.get();
boolean changed = false;
if (!Objects.equals(current.getPath(), path)) {
current.setPath(path);
changed = true;
}
if (!Objects.equals(current.getComponent(), component)) {
current.setComponent(component);
changed = true;
}
if (changed) {
current.setUpdatedBy("apitest-seed");
current.setUpdatedTime(new Date());
sysMenuMapper.updateById(current);
log.info("Updated apitest sys_menu '{}' path='{}' component='{}'", menuName, path, component);
}
return current;
}
SysMenuEntity entity = new SysMenuEntity();
entity.setMenuName(menuName);
entity.setMenuCode(menuCode);
entity.setIcon("el-icon-menu");
entity.setPermission("");
entity.setMenuLevel(parentId == 0 ? 1L : 2L);
entity.setSort(sort);
entity.setParentId(parentId);
entity.setMenuType(menuType);
entity.setStatus(0);
entity.setRemark(menuName);
entity.setPath(path);
entity.setComponent(component);
entity.setRouterQuery("");
entity.setIsFrame(0);
entity.setVisible(1);
entity.setDeleted(Boolean.FALSE);
entity.setCreatedBy("apitest-seed");
entity.setCreatedTime(new Date());
entity.setUpdatedBy("apitest-seed");
entity.setUpdatedTime(new Date());
sysMenuMapper.insert(entity);
log.info("Inserted apitest sys_menu '{}' path='{}' parentId={}", menuName, path, parentId);
return entity;
}
private void seedTenantPackage() { private void seedTenantPackage() {
long existing = tenantPackageService.count(Wrappers.<SysTenantPackageEntity>lambdaQuery() long existing = tenantPackageService.count(Wrappers.<SysTenantPackageEntity>lambdaQuery()
.eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID)); .eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID));
@@ -177,6 +295,28 @@ public class ApiTestDataSeeder implements CommandLineRunner {
private void seedTenant() { private void seedTenant() {
SysTenantEntity tenant = tenantService.getById(DEFAULT_TENANT_ID); SysTenantEntity tenant = tenantService.getById(DEFAULT_TENANT_ID);
if (tenant != null) { if (tenant != null) {
boolean changed = false;
if (tenant.getAppId() == null || tenant.getAppId().isEmpty()) {
tenant.setAppId("wx-apitest-appid");
changed = true;
}
if (tenant.getSecret() == null || tenant.getSecret().isEmpty()) {
tenant.setSecret("wx-apitest-secret");
changed = true;
}
if (tenant.getMchId() == null || tenant.getMchId().isEmpty()) {
tenant.setMchId("wx-apitest-mchid");
changed = true;
}
if (tenant.getMchKey() == null || tenant.getMchKey().isEmpty()) {
tenant.setMchKey("wx-apitest-mchkey");
changed = true;
}
if (changed) {
tenantService.updateById(tenant);
log.info("API test tenant {} already exists, wechat config refreshed", DEFAULT_TENANT_ID);
return;
}
log.info("API test tenant {} already exists", DEFAULT_TENANT_ID); log.info("API test tenant {} already exists", DEFAULT_TENANT_ID);
return; return;
} }
@@ -188,6 +328,10 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setTenantStatus("0"); entity.setTenantStatus("0");
entity.setTenantCode("apitest"); entity.setTenantCode("apitest");
entity.setTenantKey(DEFAULT_TENANT_KEY); entity.setTenantKey(DEFAULT_TENANT_KEY);
entity.setAppId("wx-apitest-appid");
entity.setSecret("wx-apitest-secret");
entity.setMchId("wx-apitest-mchid");
entity.setMchKey("wx-apitest-mchkey");
entity.setPackageId(DEFAULT_PACKAGE_ID); entity.setPackageId(DEFAULT_PACKAGE_ID);
entity.setTenantTime(new Date(System.currentTimeMillis() + 365L * 24 * 3600 * 1000)); entity.setTenantTime(new Date(System.currentTimeMillis() + 365L * 24 * 3600 * 1000));
entity.setUserName(DEFAULT_ADMIN_USERNAME); entity.setUserName(DEFAULT_ADMIN_USERNAME);
@@ -200,7 +344,8 @@ public class ApiTestDataSeeder implements CommandLineRunner {
} }
private void seedAdminUser() { private void seedAdminUser() {
SysUserEntity existing = sysUserService.getById(DEFAULT_ADMIN_USER_ID); SysUserEntity existing = sysUserMapper.selectUserByUserNameAndTenantId(
DEFAULT_ADMIN_USERNAME, DEFAULT_TENANT_ID);
if (existing != null) { if (existing != null) {
log.info("API test admin user {} already exists", DEFAULT_ADMIN_USER_ID); log.info("API test admin user {} already exists", DEFAULT_ADMIN_USER_ID);
return; return;
@@ -237,8 +382,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setGroupName("测试小组"); entity.setGroupName("测试小组");
entity.setLeaderName("API Admin"); entity.setLeaderName("API Admin");
entity.setAddTime(LocalDateTime.now()); entity.setAddTime(LocalDateTime.now());
personnelGroupInfoService.save(entity); try {
log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID); personnelGroupInfoService.save(entity);
log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID);
} catch (DuplicateKeyException duplicateKeyException) {
log.info("API test personnel group {} already inserted by another test context", DEFAULT_GROUP_ID);
}
} }
private void seedClerkLevel() { private void seedClerkLevel() {
@@ -260,12 +409,27 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setFirstRewardRatio(40); entity.setFirstRewardRatio(40);
entity.setNotFirstRewardRatio(35); entity.setNotFirstRewardRatio(35);
entity.setOrderNumber(1L); entity.setOrderNumber(1L);
clerkLevelInfoService.save(entity); try {
log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID); clerkLevelInfoService.save(entity);
log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
} catch (DuplicateKeyException duplicateKeyException) {
log.info("API test clerk level {} already inserted by another test context", DEFAULT_CLERK_LEVEL_ID);
}
} }
private PlayCommodityInfoEntity seedCommodityHierarchy() { private PlayCommodityInfoEntity seedCommodityHierarchy() {
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID); PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
if (parent == null) {
PlayCommodityInfoEntity existingParent = commodityInfoMapper
.selectByIdIncludingDeleted(DEFAULT_COMMODITY_PARENT_ID);
if (existingParent != null) {
commodityInfoMapper.restoreCommodity(DEFAULT_COMMODITY_PARENT_ID, DEFAULT_TENANT_ID);
parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
if (parent == null) {
parent = existingParent;
}
}
}
if (parent == null) { if (parent == null) {
parent = new PlayCommodityInfoEntity(); parent = new PlayCommodityInfoEntity();
parent.setId(DEFAULT_COMMODITY_PARENT_ID); parent.setId(DEFAULT_COMMODITY_PARENT_ID);
@@ -302,6 +466,17 @@ public class ApiTestDataSeeder implements CommandLineRunner {
} }
PlayCommodityInfoEntity child = commodityInfoService.getById(DEFAULT_COMMODITY_ID); PlayCommodityInfoEntity child = commodityInfoService.getById(DEFAULT_COMMODITY_ID);
if (child == null) {
PlayCommodityInfoEntity existingChild = commodityInfoMapper
.selectByIdIncludingDeleted(DEFAULT_COMMODITY_ID);
if (existingChild != null) {
commodityInfoMapper.restoreCommodity(DEFAULT_COMMODITY_ID, DEFAULT_TENANT_ID);
child = commodityInfoService.getById(DEFAULT_COMMODITY_ID);
if (child == null) {
child = existingChild;
}
}
}
if (child != null) { if (child != null) {
boolean childNeedsUpdate = false; boolean childNeedsUpdate = false;
if (!DEFAULT_COMMODITY_PARENT_ID.equals(child.getPId())) { if (!DEFAULT_COMMODITY_PARENT_ID.equals(child.getPId())) {
@@ -370,8 +545,27 @@ public class ApiTestDataSeeder implements CommandLineRunner {
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(DEFAULT_CLERK_ID); PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(DEFAULT_CLERK_ID);
String clerkToken = wxTokenService.createWxUserToken(DEFAULT_CLERK_ID); String clerkToken = wxTokenService.createWxUserToken(DEFAULT_CLERK_ID);
if (clerk != null) { if (clerk != null) {
clerkUserInfoService.updateTokenById(DEFAULT_CLERK_ID, clerkToken); clerkUserInfoService.update(Wrappers.<PlayClerkUserInfoEntity>lambdaUpdate()
log.info("API test clerk {} already exists", DEFAULT_CLERK_ID); .eq(PlayClerkUserInfoEntity::getId, DEFAULT_CLERK_ID)
.set(PlayClerkUserInfoEntity::getDeleted, Boolean.FALSE)
.set(PlayClerkUserInfoEntity::getOnboardingState, OnboardingStatus.ACTIVE.getCode())
.set(PlayClerkUserInfoEntity::getListingState, ListingStatus.LISTED.getCode())
.set(PlayClerkUserInfoEntity::getDisplayState, "1")
.set(PlayClerkUserInfoEntity::getRandomOrderState, "1")
.set(PlayClerkUserInfoEntity::getClerkState, ClerkRoleStatus.CLERK.getCode())
.set(PlayClerkUserInfoEntity::getOnlineState, "1")
.set(PlayClerkUserInfoEntity::getAvatar, "https://example.com/avatar.png")
.set(PlayClerkUserInfoEntity::getToken, clerkToken));
log.info("API test clerk {} already exists, state refreshed", DEFAULT_CLERK_ID);
return;
}
PlayClerkUserInfoEntity existing = clerkUserInfoMapper.selectByIdIncludingDeleted(DEFAULT_CLERK_ID);
if (existing != null) {
clerkUserInfoService.update(Wrappers.<PlayClerkUserInfoEntity>lambdaUpdate()
.eq(PlayClerkUserInfoEntity::getId, DEFAULT_CLERK_ID)
.set(PlayClerkUserInfoEntity::getDeleted, Boolean.FALSE)
.set(PlayClerkUserInfoEntity::getToken, clerkToken));
log.info("API test clerk {} restored from deleted state", DEFAULT_CLERK_ID);
return; return;
} }
@@ -403,26 +597,38 @@ public class ApiTestDataSeeder implements CommandLineRunner {
private void seedClerkCommodity() { private void seedClerkCommodity() {
PlayClerkCommodityEntity mapping = clerkCommodityService.getById(DEFAULT_CLERK_COMMODITY_ID); PlayClerkCommodityEntity mapping = clerkCommodityService.getById(DEFAULT_CLERK_COMMODITY_ID);
if (mapping != null) {
log.info("API test clerk commodity {} already exists", DEFAULT_CLERK_COMMODITY_ID);
return;
}
String commodityName = DEFAULT_COMMODITY_PARENT_NAME; String commodityName = DEFAULT_COMMODITY_PARENT_NAME;
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID); PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
if (parent != null && parent.getItemName() != null) { if (parent != null && parent.getItemName() != null) {
commodityName = parent.getItemName(); commodityName = parent.getItemName();
} }
if (mapping != null) {
clerkCommodityService.update(Wrappers.<PlayClerkCommodityEntity>lambdaUpdate()
.eq(PlayClerkCommodityEntity::getId, DEFAULT_CLERK_COMMODITY_ID)
.set(PlayClerkCommodityEntity::getCommodityId, DEFAULT_COMMODITY_PARENT_ID)
.set(PlayClerkCommodityEntity::getCommodityName, commodityName)
.set(PlayClerkCommodityEntity::getEnablingState, "1"));
log.info("API test clerk commodity {} already exists, state refreshed", DEFAULT_CLERK_COMMODITY_ID);
return;
}
PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity(); PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity();
entity.setId(DEFAULT_CLERK_COMMODITY_ID); entity.setId(DEFAULT_CLERK_COMMODITY_ID);
entity.setTenantId(DEFAULT_TENANT_ID); entity.setTenantId(DEFAULT_TENANT_ID);
entity.setClerkId(DEFAULT_CLERK_ID); entity.setClerkId(DEFAULT_CLERK_ID);
entity.setCommodityId(DEFAULT_COMMODITY_ID); entity.setCommodityId(DEFAULT_COMMODITY_PARENT_ID);
entity.setCommodityName(commodityName); entity.setCommodityName(commodityName);
entity.setEnablingState("1"); entity.setEnablingState("1");
entity.setSort(1); entity.setSort(1);
clerkCommodityService.save(entity); try {
clerkCommodityService.save(entity);
} catch (DuplicateKeyException duplicateKeyException) {
log.info(
"API test clerk commodity {} already inserted by another test context",
DEFAULT_CLERK_COMMODITY_ID);
return;
}
log.info("Inserted API test clerk commodity link {}", DEFAULT_CLERK_COMMODITY_ID); log.info("Inserted API test clerk commodity link {}", DEFAULT_CLERK_COMMODITY_ID);
} }
@@ -445,7 +651,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setState(GIFT_STATE_ACTIVE); entity.setState(GIFT_STATE_ACTIVE);
entity.setListingTime(LocalDateTime.now()); entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded gift for API tests"); entity.setRemark("Seeded gift for API tests");
giftInfoService.save(entity); try {
giftInfoService.save(entity);
} catch (DuplicateKeyException duplicateKeyException) {
log.info("API test gift {} already inserted by another test context", DEFAULT_GIFT_ID);
return;
}
log.info("Inserted API test gift {}", DEFAULT_GIFT_ID); log.info("Inserted API test gift {}", DEFAULT_GIFT_ID);
} }
@@ -504,7 +715,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity(); PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
entity.setId(DEFAULT_CUSTOMER_ID); entity.setId(DEFAULT_CUSTOMER_ID);
entity.setTenantId(DEFAULT_TENANT_ID); entity.setTenantId(DEFAULT_TENANT_ID);
entity.setOpenid("openid-customer-apitest"); entity.setOpenid(DEFAULT_CUSTOMER_OPEN_ID);
entity.setUnionid("unionid-customer-apitest"); entity.setUnionid("unionid-customer-apitest");
entity.setNickname("测试顾客"); entity.setNickname("测试顾客");
entity.setSex(1); entity.setSex(1);
@@ -520,7 +731,24 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setRegistrationTime(new Date()); entity.setRegistrationTime(new Date());
entity.setLastLoginTime(new Date()); entity.setLastLoginTime(new Date());
entity.setToken(token); entity.setToken(token);
customUserInfoService.save(entity); try {
customUserInfoService.save(entity);
} catch (DuplicateKeyException duplicateKeyException) {
customUserInfoService.updateTokenById(DEFAULT_CUSTOMER_ID, token);
customUserInfoService.lambdaUpdate()
.set(PlayCustomUserInfoEntity::getAccountBalance, DEFAULT_CUSTOMER_BALANCE)
.set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, DEFAULT_CUSTOMER_RECHARGE)
.set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO)
.set(PlayCustomUserInfoEntity::getAccountState, "1")
.set(PlayCustomUserInfoEntity::getSubscribeState, "1")
.set(PlayCustomUserInfoEntity::getPurchaseState, "1")
.set(PlayCustomUserInfoEntity::getMobilePhoneState, "1")
.set(PlayCustomUserInfoEntity::getLastLoginTime, new Date())
.eq(PlayCustomUserInfoEntity::getId, DEFAULT_CUSTOMER_ID)
.update();
log.info("API test customer {} already inserted by another test context", DEFAULT_CUSTOMER_ID);
return;
}
log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID); log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID);
} }
} }

View File

@@ -42,7 +42,10 @@ public class PermissionService {
} }
LoginUser loginUser = SecurityUtils.getLoginUser(); LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) { if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) {
return false; return loginUser != null && loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser());
}
if (loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser())) {
return true;
} }
return hasPermissions(loginUser.getPermissions(), permission); return hasPermissions(loginUser.getPermissions(), permission);
} }
@@ -70,7 +73,13 @@ public class PermissionService {
return false; return false;
} }
LoginUser loginUser = SecurityUtils.getLoginUser(); LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) { if (loginUser == null) {
return false;
}
if (loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser())) {
return true;
}
if (CollectionUtils.isEmpty(loginUser.getPermissions())) {
return false; return false;
} }
Set<String> authorities = loginUser.getPermissions(); Set<String> authorities = loginUser.getPermissions();

View File

@@ -0,0 +1,32 @@
package com.starry.admin.common.config;
import com.starry.admin.modules.weichat.constant.WebSocketConstant;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket 配置,基于 STOMP 的简单消息代理。
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private static final String APPLICATION_DESTINATION_PREFIX = "/app";
private static final String TOPIC_DESTINATION_PREFIX = "/topic";
private static final String PK_ENDPOINT = "/ws/pk";
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker(TOPIC_DESTINATION_PREFIX);
registry.setApplicationDestinationPrefixes(APPLICATION_DESTINATION_PREFIX);
registry.setUserDestinationPrefix(WebSocketConstant.USER_DESTINATION_PREFIX);
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint(PK_ENDPOINT).setAllowedOriginPatterns("*");
}
}

View File

@@ -8,7 +8,11 @@ import com.starry.common.utils.StringUtils;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException;
@@ -24,6 +28,17 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
public static final String PARAMETER_FORMAT_ERROR = "请求参数格式异常";
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<R> handleAccessDenied(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(R.error(403, e.getMessage()));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<R> handleAuthentication(AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(R.error(401, e.getMessage()));
}
/** /**
* 业务异常 * 业务异常
@@ -87,20 +102,20 @@ public class GlobalExceptionHandler {
@ExceptionHandler(MismatchedInputException.class) @ExceptionHandler(MismatchedInputException.class)
public R mismatchedInputException(MismatchedInputException e) { public R mismatchedInputException(MismatchedInputException e) {
log.error("请求参数格式异常", e); log.error(PARAMETER_FORMAT_ERROR, e);
return R.error("请求参数格式异常"); return R.error(PARAMETER_FORMAT_ERROR);
} }
@ExceptionHandler(HttpMessageNotReadableException.class) @ExceptionHandler(HttpMessageNotReadableException.class)
public R httpMessageNotReadableException(HttpMessageNotReadableException e) { public R httpMessageNotReadableException(HttpMessageNotReadableException e) {
log.error("请求参数格式异常", e); log.error(PARAMETER_FORMAT_ERROR, e);
return R.error("请求参数格式异常"); return R.error(PARAMETER_FORMAT_ERROR);
} }
@ExceptionHandler(MissingServletRequestParameterException.class) @ExceptionHandler(MissingServletRequestParameterException.class)
public R missingServletRequestParameterException(MissingServletRequestParameterException e) { public R missingServletRequestParameterException(MissingServletRequestParameterException e) {
log.error("请求参数格式异常", e); log.error(PARAMETER_FORMAT_ERROR, e);
return R.error("请求参数格式异常"); return R.error(PARAMETER_FORMAT_ERROR);
} }
/** /**

View File

@@ -6,10 +6,14 @@ import com.starry.admin.modules.system.module.entity.SysUserEntity;
import com.starry.common.constant.SecurityConstants; import com.starry.common.constant.SecurityConstants;
import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.context.CustomSecurityContextHolder;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.HashSet; import java.util.HashSet;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import javax.servlet.FilterChain; import javax.servlet.FilterChain;
import javax.servlet.ServletException; import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -24,6 +28,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
public class ApiTestAuthenticationFilter extends OncePerRequestFilter { public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
private final ApiTestSecurityProperties properties; private final ApiTestSecurityProperties properties;
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
public ApiTestAuthenticationFilter(ApiTestSecurityProperties properties) { public ApiTestAuthenticationFilter(ApiTestSecurityProperties properties) {
this.properties = properties; this.properties = properties;
@@ -32,6 +38,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
Map<String, Object> originalContext = new ConcurrentHashMap<>(CustomSecurityContextHolder.getLocalMap());
String requestedUser = request.getHeader(properties.getUserHeader()); String requestedUser = request.getHeader(properties.getUserHeader());
String requestedTenant = request.getHeader(properties.getTenantHeader()); String requestedTenant = request.getHeader(properties.getTenantHeader());
@@ -48,6 +55,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
try { try {
LoginUser loginUser = buildLoginUser(userId, tenantId); LoginUser loginUser = buildLoginUser(userId, tenantId);
applyOverridesFromHeaders(request, loginUser);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null,
Collections.emptyList()); Collections.emptyList());
@@ -61,7 +69,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} finally { } finally {
CustomSecurityContextHolder.remove(); CustomSecurityContextHolder.setLocalMap(originalContext);
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
} }
} }
@@ -93,4 +101,27 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
return loginUser; return loginUser;
} }
private void applyOverridesFromHeaders(HttpServletRequest request, LoginUser loginUser) {
if (loginUser == null || loginUser.getUser() == null) {
return;
}
String superAdmin = request.getHeader(SUPER_ADMIN_HEADER);
if (StringUtils.hasText(superAdmin)) {
loginUser.getUser().setSuperAdmin(Boolean.parseBoolean(superAdmin));
}
String permissionsHeader = request.getHeader(PERMISSIONS_HEADER);
if (!StringUtils.hasText(permissionsHeader)) {
return;
}
Set<String> perms = Arrays.stream(permissionsHeader.split(","))
.map(String::trim)
.filter(StringUtils::hasText)
.collect(Collectors.toSet());
loginUser.setPermissions(perms);
CustomSecurityContextHolder.setPermission(String.join(",", perms));
}
} }

View File

@@ -1,24 +1,25 @@
package com.starry.admin.common.task; package com.starry.admin.common.task;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkWagesDetailsInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkWagesDetailsInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkWagesInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkWagesInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkWagesDetailsInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkWagesDetailsInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkWagesInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkWagesInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import com.starry.admin.modules.system.service.ISysTenantService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@@ -128,14 +129,13 @@ public class ClerkWagesSettlementTask {
finalAmount = finalAmount.add(orderInfo.getFinalAmount()); finalAmount = finalAmount.add(orderInfo.getFinalAmount());
orderNumber++; orderNumber++;
estimatedRevenue = estimatedRevenue.add(orderInfo.getEstimatedRevenue()); estimatedRevenue = estimatedRevenue.add(orderInfo.getEstimatedRevenue());
if ("0".equals(orderInfo.getFirstOrder())) {
orderContinueNumber++;
orderContinueMoney = orderContinueMoney.add(orderInfo.getFinalAmount());
}
if ("1".equals(orderInfo.getOrdersExpiredState())) { if ("1".equals(orderInfo.getOrdersExpiredState())) {
ordersExpiredNumber++; ordersExpiredNumber++;
} }
} }
ContinuationMetrics continuationMetrics = calculateContinuationMetrics(orderInfoEntities);
orderContinueNumber = continuationMetrics.count;
orderContinueMoney = continuationMetrics.money;
PlayClerkWagesInfoEntity wagesInfo = new PlayClerkWagesInfoEntity(); PlayClerkWagesInfoEntity wagesInfo = new PlayClerkWagesInfoEntity();
wagesInfo.setId(wagesId); wagesInfo.setId(wagesId);
@@ -158,4 +158,51 @@ public class ClerkWagesSettlementTask {
playClerkWagesInfoService.saveOrUpdate(wagesInfo); playClerkWagesInfoService.saveOrUpdate(wagesInfo);
} }
private ContinuationMetrics calculateContinuationMetrics(List<PlayOrderInfoEntity> orders) {
List<PlayOrderInfoEntity> completedOrders = orders.stream()
.filter(order -> OrderConstant.OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus()))
.collect(Collectors.toList());
if (completedOrders.isEmpty()) {
return new ContinuationMetrics(0, BigDecimal.ZERO);
}
int continuedCount = 0;
BigDecimal continuedMoney = BigDecimal.ZERO;
for (PlayOrderInfoEntity order : completedOrders) {
String customerId = order.getPurchaserBy();
if (StrUtil.isBlank(customerId)) {
throw new CustomException("订单缺少顾客信息,无法统计续单");
}
OrderConstant.OrderRelationType relationType = normalizeRelationType(order);
if (relationType == OrderConstant.OrderRelationType.CONTINUED) {
continuedCount++;
continuedMoney = continuedMoney.add(order.getFinalAmount());
}
}
return new ContinuationMetrics(continuedCount, continuedMoney);
}
private OrderConstant.OrderRelationType normalizeRelationType(PlayOrderInfoEntity order) {
OrderConstant.OrderRelationType relationType = order.getOrderRelationType();
if (relationType == null) {
throw new CustomException("订单关系类型不能为空");
}
if (OrderConstant.PlaceType.RANDOM.getCode().equals(order.getPlaceType())) {
return OrderConstant.OrderRelationType.FIRST;
}
if (relationType == OrderConstant.OrderRelationType.NEUTRAL) {
return OrderConstant.OrderRelationType.FIRST;
}
return relationType;
}
private static final class ContinuationMetrics {
private final int count;
private final BigDecimal money;
private ContinuationMetrics(int count, BigDecimal money) {
this.count = count;
this.money = money;
}
}
} }

View File

@@ -1,9 +1,12 @@
package com.starry.admin.common.task; package com.starry.admin.common.task;
import cn.hutool.core.util.StrUtil;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.PlayClerkRankingInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkRankingInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkRankingInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkRankingInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.system.module.entity.SysTenantEntity; import com.starry.admin.modules.system.module.entity.SysTenantEntity;
@@ -20,6 +23,7 @@ import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -134,14 +138,13 @@ public class OrderRankingSettlementTask {
for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) { for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) {
customIds.add(orderInfoEntity.getAcceptBy()); customIds.add(orderInfoEntity.getAcceptBy());
orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney()); orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney());
if ("0".equals(orderInfoEntity.getFirstOrder())) {
orderContinueNumber++;
orderContinueMoney = orderContinueMoney.add(orderInfoEntity.getOrderMoney());
}
if ("1".equals(orderInfoEntity.getOrdersExpiredState())) { if ("1".equals(orderInfoEntity.getOrdersExpiredState())) {
ordersExpiredNumber++; ordersExpiredNumber++;
} }
} }
ContinuationMetrics continuationMetrics = calculateContinuationMetrics(orderInfoEntities);
orderContinueNumber = continuationMetrics.count;
orderContinueMoney = continuationMetrics.money;
BigDecimal orderContinueProportion = orderNumber == 0 ? BigDecimal.ZERO : new BigDecimal(ordersExpiredNumber).divide(new BigDecimal(orderNumber), 4, RoundingMode.HALF_UP).add(new BigDecimal(100)); BigDecimal orderContinueProportion = orderNumber == 0 ? BigDecimal.ZERO : new BigDecimal(ordersExpiredNumber).divide(new BigDecimal(orderNumber), 4, RoundingMode.HALF_UP).add(new BigDecimal(100));
BigDecimal averageUnitPrice = customIds.isEmpty() ? BigDecimal.ZERO : orderMoney.divide(new BigDecimal(customIds.size()), 4, RoundingMode.HALF_UP); BigDecimal averageUnitPrice = customIds.isEmpty() ? BigDecimal.ZERO : orderMoney.divide(new BigDecimal(customIds.size()), 4, RoundingMode.HALF_UP);
PlayClerkRankingInfoEntity rankingInfo = new PlayClerkRankingInfoEntity(); PlayClerkRankingInfoEntity rankingInfo = new PlayClerkRankingInfoEntity();
@@ -170,4 +173,51 @@ public class OrderRankingSettlementTask {
return rankingInfo; return rankingInfo;
} }
private ContinuationMetrics calculateContinuationMetrics(List<PlayOrderInfoEntity> orders) {
List<PlayOrderInfoEntity> completedOrders = orders.stream()
.filter(order -> OrderConstant.OrderStatus.COMPLETED.getCode().equals(order.getOrderStatus()))
.collect(Collectors.toList());
if (completedOrders.isEmpty()) {
return new ContinuationMetrics(0, BigDecimal.ZERO);
}
int continuedCount = 0;
BigDecimal continuedMoney = BigDecimal.ZERO;
for (PlayOrderInfoEntity order : completedOrders) {
String customerId = order.getPurchaserBy();
if (StrUtil.isBlank(customerId)) {
throw new CustomException("订单缺少顾客信息,无法统计续单");
}
OrderConstant.OrderRelationType relationType = normalizeRelationType(order);
if (relationType == OrderConstant.OrderRelationType.CONTINUED) {
continuedCount++;
continuedMoney = continuedMoney.add(order.getOrderMoney());
}
}
return new ContinuationMetrics(continuedCount, continuedMoney);
}
private OrderConstant.OrderRelationType normalizeRelationType(PlayOrderInfoEntity order) {
OrderConstant.OrderRelationType relationType = order.getOrderRelationType();
if (relationType == null) {
throw new CustomException("订单关系类型不能为空");
}
if (OrderConstant.PlaceType.RANDOM.getCode().equals(order.getPlaceType())) {
return OrderConstant.OrderRelationType.FIRST;
}
if (relationType == OrderConstant.OrderRelationType.NEUTRAL) {
return OrderConstant.OrderRelationType.FIRST;
}
return relationType;
}
private static final class ContinuationMetrics {
private final int count;
private final BigDecimal money;
private ContinuationMetrics(int count, BigDecimal money) {
this.count = count;
this.money = money;
}
}
} }

View File

@@ -62,6 +62,7 @@ public class BlindBoxDispatchService {
.orderType(OrderConstant.OrderType.GIFT) .orderType(OrderConstant.OrderType.GIFT)
.placeType(OrderConstant.PlaceType.REWARD) .placeType(OrderConstant.PlaceType.REWARD)
.rewardType(OrderConstant.RewardType.GIFT) .rewardType(OrderConstant.RewardType.GIFT)
.orderRelationType(OrderConstant.OrderRelationType.UNASSIGNED)
.paymentSource(OrderConstant.PaymentSource.BLIND_BOX) .paymentSource(OrderConstant.PaymentSource.BLIND_BOX)
.sourceRewardId(reward.getId()) .sourceRewardId(reward.getId())
.paymentInfo(paymentInfo) .paymentInfo(paymentInfo)

View File

@@ -3,6 +3,10 @@ package com.starry.admin.modules.clerk.controller;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService; import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
import com.starry.admin.modules.pk.dto.PlayClerkPkForceStartRequest;
import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
import com.starry.admin.modules.pk.service.IPkScoreboardService;
import com.starry.common.annotation.Log; import com.starry.common.annotation.Log;
import com.starry.common.enums.BusinessType; import com.starry.common.enums.BusinessType;
import com.starry.common.result.R; import com.starry.common.result.R;
@@ -13,7 +17,13 @@ import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** /**
* 店员pkController * 店员pkController
@@ -28,6 +38,12 @@ public class PlayClerkPkController {
@Resource @Resource
private IPlayClerkPkService playClerkPkService; private IPlayClerkPkService playClerkPkService;
@Resource
private IPkScoreboardService pkScoreboardService;
@Resource
private ClerkPkLifecycleService clerkPkLifecycleService;
/** /**
* 查询店员pk列表 * 查询店员pk列表
*/ */
@@ -51,6 +67,52 @@ public class PlayClerkPkController {
return R.ok(playClerkPkService.selectPlayClerkPkById(id)); return R.ok(playClerkPkService.selectPlayClerkPkById(id));
} }
/**
* 获取店员PK实时比分
*/
@ApiOperation(value = "获取PK实时比分", notes = "根据ID获取店员PK当前比分")
@ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = PkScoreBoardDto.class)})
@GetMapping(value = "/{id}/scoreboard")
public R getScoreboard(@PathVariable("id") String id) {
PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(id);
return R.ok(scoreboard);
}
/**
* 手动开始PK
*/
@ApiOperation(value = "开始PK", notes = "将指定PK从待开始状态切换为进行中")
@ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
@PostMapping(value = "/{id}/start")
public R startPk(@PathVariable("id") String id) {
clerkPkLifecycleService.startPk(id);
return R.ok();
}
/**
* 手动结束并结算PK
*/
@ApiOperation(value = "结束PK并结算", notes = "将指定PK标记为已完成并写入最终比分和胜者信息")
@ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
@PostMapping(value = "/{id}/finish")
public R finishPk(@PathVariable("id") String id) {
clerkPkLifecycleService.finishPk(id);
return R.ok();
}
/**
* 强制开始PK无需排期便于人工触发
*/
@ApiOperation(value = "强制开始PK", notes = "人工触发PK开始并直接进入进行中状态")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
@Log(title = "店员pk", businessType = BusinessType.INSERT)
@PostMapping(value = "/force-start")
public R forceStart(@ApiParam(value = "强制开始请求", required = true)
@RequestBody PlayClerkPkForceStartRequest request) {
return R.ok(clerkPkLifecycleService.forceStart(request));
}
/** /**
* 新增店员pk * 新增店员pk
*/ */

View File

@@ -1,7 +1,12 @@
package com.starry.admin.modules.clerk.mapper; package com.starry.admin.modules.clerk.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** /**
* 店员pkMapper接口 * 店员pkMapper接口
@@ -11,4 +16,47 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
*/ */
public interface PlayClerkPkMapper extends BaseMapper<PlayClerkPkEntity> { public interface PlayClerkPkMapper extends BaseMapper<PlayClerkPkEntity> {
@InterceptorIgnore(tenantLine = "1")
@Select("SELECT * FROM play_clerk_pk "
+ "WHERE status = #{status} "
+ "AND pk_begin_time >= #{beginTime} "
+ "AND pk_begin_time <= #{endTime}")
List<PlayClerkPkEntity> selectUpcomingByStatus(
@Param("status") String status,
@Param("beginTime") Date beginTime,
@Param("endTime") Date endTime);
@Select("<script>"
+ "SELECT * FROM play_clerk_pk "
+ "WHERE tenant_id = #{tenantId} "
+ " AND status = #{status} "
+ " AND ("
+ " (clerk_a = #{clerkAId} AND clerk_b = #{clerkBId}) "
+ " OR (clerk_a = #{clerkBId} AND clerk_b = #{clerkAId})"
+ " ) "
+ "ORDER BY pk_begin_time DESC "
+ "LIMIT #{limit}"
+ "</script>")
List<PlayClerkPkEntity> selectRecentFinishedBetweenClerks(
@Param("tenantId") String tenantId,
@Param("clerkAId") String clerkAId,
@Param("clerkBId") String clerkBId,
@Param("status") String status,
@Param("limit") int limit);
@Select("<script>"
+ "SELECT * FROM play_clerk_pk "
+ "WHERE tenant_id = #{tenantId} "
+ " AND status = #{status} "
+ " AND pk_begin_time &gt;= #{beginTime} "
+ " AND (clerk_a = #{clerkId} OR clerk_b = #{clerkId}) "
+ "ORDER BY pk_begin_time ASC "
+ "LIMIT #{limit}"
+ "</script>")
List<PlayClerkPkEntity> selectUpcomingForClerk(
@Param("tenantId") String tenantId,
@Param("clerkId") String clerkId,
@Param("status") String status,
@Param("beginTime") Date beginTime,
@Param("limit") int limit);
} }

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.github.yulichang.base.MPJBaseMapper; import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
/** /**
@@ -17,4 +18,8 @@ public interface PlayClerkUserInfoMapper extends MPJBaseMapper<PlayClerkUserInfo
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")
@Select("SELECT id, tenant_id, album FROM play_clerk_user_info WHERE deleted = 0 AND album IS NOT NULL") @Select("SELECT id, tenant_id, album FROM play_clerk_user_info WHERE deleted = 0 AND album IS NOT NULL")
List<PlayClerkUserInfoEntity> selectAllWithAlbumIgnoringTenant(); List<PlayClerkUserInfoEntity> selectAllWithAlbumIgnoringTenant();
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT id, tenant_id, deleted FROM play_clerk_user_info WHERE id = #{id} LIMIT 1")
PlayClerkUserInfoEntity selectByIdIncludingDeleted(@Param("id") String id);
} }

View File

@@ -87,4 +87,39 @@ public class PlayClerkPkEntity extends BaseEntity<PlayClerkPkEntity> {
*/ */
private String status; private String status;
/**
* 排期设置ID
*/
private String settingId;
/**
* 店员A得分
*/
private java.math.BigDecimal clerkAScore;
/**
* 店员B得分
*/
private java.math.BigDecimal clerkBScore;
/**
* 店员A订单数
*/
private Integer clerkAOrderCount;
/**
* 店员B订单数
*/
private Integer clerkBOrderCount;
/**
* 获胜店员ID
*/
private String winnerClerkId;
/**
* 是否已结算(1:是;0:否)
*/
private Integer settled;
} }

View File

@@ -3,6 +3,10 @@ package com.starry.admin.modules.clerk.service;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
import java.util.Optional;
/** /**
* 店员pkService接口 * 店员pkService接口
@@ -64,4 +68,15 @@ public interface IPlayClerkPkService extends IService<PlayClerkPkEntity> {
* @return 结果 * @return 结果
*/ */
int deletePlayClerkPkById(String id); int deletePlayClerkPkById(String id);
/**
* 查询某个店员在指定时间是否存在进行中的 PK。
*
* @param clerkId 店员ID
* @param occurredAt 发生时间
* @return 存在则返回 PK 记录,否则返回空
*/
Optional<PlayClerkPkEntity> findActivePkForClerk(String clerkId, LocalDateTime occurredAt);
List<PlayClerkPkEntity> selectUpcomingForClerk(String tenantId, String clerkId, Date beginTime, int limit);
} }

View File

@@ -7,16 +7,25 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.common.PageBuilder;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper; import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper;
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum; import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService; import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.List;
import java.util.Optional;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
@@ -33,6 +42,8 @@ public class PlayClerkPkServiceImpl extends ServiceImpl<PlayClerkPkMapper, PlayC
private PlayClerkPkMapper playClerkPkMapper; private PlayClerkPkMapper playClerkPkMapper;
@Resource @Resource
private IPlayClerkUserInfoService clerkUserInfoService; private IPlayClerkUserInfoService clerkUserInfoService;
@Resource
private StringRedisTemplate stringRedisTemplate;
/** /**
* 查询店员pk * 查询店员pk
@@ -55,8 +66,15 @@ public class PlayClerkPkServiceImpl extends ServiceImpl<PlayClerkPkMapper, PlayC
*/ */
@Override @Override
public IPage<PlayClerkPkEntity> selectPlayClerkPkByPage(PlayClerkPkEntity playClerkPk) { public IPage<PlayClerkPkEntity> selectPlayClerkPkByPage(PlayClerkPkEntity playClerkPk) {
Page<PlayClerkPkEntity> page = new Page<>(1, 10); Page<PlayClerkPkEntity> page = PageBuilder.build();
return this.baseMapper.selectPage(page, new LambdaQueryWrapper<>()); LambdaQueryWrapper<PlayClerkPkEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StrUtil.isNotBlank(playClerkPk.getStatus()), PlayClerkPkEntity::getStatus, playClerkPk.getStatus());
wrapper.eq(StrUtil.isNotBlank(playClerkPk.getClerkA()), PlayClerkPkEntity::getClerkA, playClerkPk.getClerkA());
wrapper.eq(StrUtil.isNotBlank(playClerkPk.getClerkB()), PlayClerkPkEntity::getClerkB, playClerkPk.getClerkB());
wrapper.eq(StrUtil.isNotBlank(playClerkPk.getSettingId()), PlayClerkPkEntity::getSettingId,
playClerkPk.getSettingId());
wrapper.orderByDesc(PlayClerkPkEntity::getPkBeginTime);
return this.baseMapper.selectPage(page, wrapper);
} }
/** /**
@@ -98,7 +116,11 @@ public class PlayClerkPkServiceImpl extends ServiceImpl<PlayClerkPkMapper, PlayC
} }
playClerkPk.setStatus(ClerkPkEnum.TO_BE_STARTED.name()); playClerkPk.setStatus(ClerkPkEnum.TO_BE_STARTED.name());
return save(playClerkPk); boolean saved = save(playClerkPk);
if (saved) {
scheduleStart(playClerkPk);
}
return saved;
} }
/** /**
@@ -136,4 +158,49 @@ public class PlayClerkPkServiceImpl extends ServiceImpl<PlayClerkPkMapper, PlayC
public int deletePlayClerkPkById(String id) { public int deletePlayClerkPkById(String id) {
return playClerkPkMapper.deleteById(id); return playClerkPkMapper.deleteById(id);
} }
private void scheduleStart(PlayClerkPkEntity pk) {
if (pk == null || pk.getPkBeginTime() == null || pk.getId() == null) {
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
}
if (StrUtil.isBlank(pk.getTenantId())) {
throw new CustomException(PkLifecycleErrorCode.TENANT_MISSING.getMessage());
}
String scheduleKey = PkRedisKeyConstants.startScheduleKey(pk.getTenantId());
long startEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond();
stringRedisTemplate.opsForZSet().add(scheduleKey, pk.getId(), startEpochSeconds);
}
@Override
public Optional<PlayClerkPkEntity> findActivePkForClerk(String clerkId, LocalDateTime occurredAt) {
if (StrUtil.isBlank(clerkId) || occurredAt == null) {
return Optional.empty();
}
Date eventTime =
Date.from(occurredAt.atZone(ZoneId.systemDefault()).toInstant());
LambdaQueryWrapper<PlayClerkPkEntity> wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class)
.in(PlayClerkPkEntity::getStatus,
Arrays.asList(ClerkPkEnum.TO_BE_STARTED.name(), ClerkPkEnum.IN_PROGRESS.name()))
.and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId)
.or()
.eq(PlayClerkPkEntity::getClerkB, clerkId))
.le(PlayClerkPkEntity::getPkBeginTime, eventTime)
.ge(PlayClerkPkEntity::getPkEndTime, eventTime);
PlayClerkPkEntity entity = this.getOne(wrapper, false);
return Optional.ofNullable(entity);
}
@Override
public List<PlayClerkPkEntity> selectUpcomingForClerk(String tenantId, String clerkId, Date beginTime,
int limit) {
if (StrUtil.isBlank(tenantId) || StrUtil.isBlank(clerkId) || beginTime == null || limit <= 0) {
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
}
return playClerkPkMapper.selectUpcomingForClerk(
tenantId,
clerkId,
ClerkPkEnum.TO_BE_STARTED.name(),
beginTime,
limit);
}
} }

View File

@@ -40,6 +40,7 @@ import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService;
import com.starry.admin.modules.media.entity.PlayMediaEntity; import com.starry.admin.modules.media.entity.PlayMediaEntity;
import com.starry.admin.modules.media.enums.MediaStatus; import com.starry.admin.modules.media.enums.MediaStatus;
import com.starry.admin.modules.media.service.IPlayMediaService; import com.starry.admin.modules.media.service.IPlayMediaService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
@@ -510,7 +511,17 @@ public class PlayClerkUserInfoServiceImpl extends ServiceImpl<PlayClerkUserInfoM
int orderContinueNumber = 0; int orderContinueNumber = 0;
int orderNumber = 0; int orderNumber = 0;
for (PlayOrderInfoEntity orderInfo : playOrderInfoService.queryBySettlementOrder(record.getId(), "")) { for (PlayOrderInfoEntity orderInfo : playOrderInfoService.queryBySettlementOrder(record.getId(), "")) {
if ("0".equals(orderInfo.getFirstOrder())) { OrderConstant.OrderRelationType relationType = orderInfo.getOrderRelationType();
if (relationType == null) {
throw new CustomException("订单关系类型不能为空");
}
if (OrderConstant.PlaceType.RANDOM.getCode().equals(orderInfo.getPlaceType())) {
relationType = OrderConstant.OrderRelationType.FIRST;
}
if (relationType == OrderConstant.OrderRelationType.NEUTRAL) {
relationType = OrderConstant.OrderRelationType.FIRST;
}
if (relationType == OrderConstant.OrderRelationType.CONTINUED) {
orderContinueNumber++; orderContinueNumber++;
} }
orderNumber++; orderNumber++;

View File

@@ -18,6 +18,7 @@ import com.starry.admin.modules.custom.module.vo.PlayCustomUserQueryVo;
import com.starry.admin.modules.custom.module.vo.PlayCustomUserReturnVo; import com.starry.admin.modules.custom.module.vo.PlayCustomUserReturnVo;
import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService; import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.impl.PlayOrderInfoServiceImpl; import com.starry.admin.modules.order.service.impl.PlayOrderInfoServiceImpl;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService; import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
@@ -169,7 +170,17 @@ public class PlayCustomUserInfoServiceImpl extends ServiceImpl<PlayCustomUserInf
if (orderInfo.getId() == null) { if (orderInfo.getId() == null) {
continue; continue;
} }
if ("0".equals(orderInfo.getFirstOrder())) { OrderConstant.OrderRelationType relationType = orderInfo.getOrderRelationType();
if (relationType == null) {
throw new CustomException("订单关系类型不能为空");
}
if (OrderConstant.PlaceType.RANDOM.getCode().equals(orderInfo.getPlaceType())) {
relationType = OrderConstant.OrderRelationType.FIRST;
}
if (relationType == OrderConstant.OrderRelationType.NEUTRAL) {
relationType = OrderConstant.OrderRelationType.FIRST;
}
if (relationType == OrderConstant.OrderRelationType.CONTINUED) {
orderContinueNumber++; orderContinueNumber++;
} }
orderNumber++; orderNumber++;

View File

@@ -0,0 +1,31 @@
package com.starry.admin.modules.order.mapper;
import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.order.module.entity.PlayClerkCustomerRelationEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 客户-店员关系Mapper接口
*/
public interface PlayClerkCustomerRelationMapper extends MPJBaseMapper<PlayClerkCustomerRelationEntity> {
@Select("SELECT * FROM play_clerk_customer_relation "
+ "WHERE tenant_id = #{tenantId} AND purchaser_by = #{purchaserBy} AND accept_by = #{acceptBy} "
+ "AND deleted = 0 FOR UPDATE")
PlayClerkCustomerRelationEntity selectForUpdate(@Param("tenantId") String tenantId,
@Param("purchaserBy") String purchaserBy,
@Param("acceptBy") String acceptBy);
@Update("UPDATE play_clerk_customer_relation "
+ "SET deleted = 0, has_completed = '0', first_completed_order_id = NULL, first_completed_time = NULL "
+ "WHERE tenant_id = #{tenantId} AND purchaser_by = #{purchaserBy} AND accept_by = #{acceptBy}")
int restoreRelation(@Param("tenantId") String tenantId,
@Param("purchaserBy") String purchaserBy,
@Param("acceptBy") String acceptBy);
@Delete("DELETE FROM play_clerk_customer_relation WHERE tenant_id = #{tenantId}")
int hardDeleteByTenant(@Param("tenantId") String tenantId);
}

View File

@@ -2,6 +2,12 @@ package com.starry.admin.modules.order.mapper;
import com.github.yulichang.base.MPJBaseMapper; import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.pk.dto.WxPkContributorDto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** /**
* 订单Mapper接口 * 订单Mapper接口
@@ -11,4 +17,31 @@ import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
*/ */
public interface PlayOrderInfoMapper extends MPJBaseMapper<PlayOrderInfoEntity> { public interface PlayOrderInfoMapper extends MPJBaseMapper<PlayOrderInfoEntity> {
@Select("<script>"
+ "SELECT o.purchaser_by AS userId, "
+ " u.nickname AS nickname, "
+ " COALESCE(SUM(o.final_amount), 0) AS amount "
+ "FROM play_order_info o "
+ "LEFT JOIN play_custom_user_info u "
+ " ON u.id = o.purchaser_by "
+ " AND u.tenant_id = o.tenant_id "
+ "WHERE o.tenant_id = #{tenantId} "
+ " AND o.order_status = #{orderStatus} "
+ " AND o.final_amount &gt; #{minAmount} "
+ " AND o.accept_by IN (#{clerkAId}, #{clerkBId}) "
+ " AND o.order_end_time &gt;= #{startTime} "
+ " AND o.order_end_time &lt;= #{endTime} "
+ "GROUP BY o.purchaser_by, u.nickname "
+ "ORDER BY amount DESC "
+ "LIMIT #{limit}"
+ "</script>")
List<WxPkContributorDto> selectPkContributors(
@Param("tenantId") String tenantId,
@Param("clerkAId") String clerkAId,
@Param("clerkBId") String clerkBId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("orderStatus") String orderStatus,
@Param("minAmount") BigDecimal minAmount,
@Param("limit") int limit);
} }

View File

@@ -123,6 +123,37 @@ public class OrderConstant {
} }
} }
/**
* 订单关系类型
*/
@Getter
public enum OrderRelationType {
UNASSIGNED("UNASSIGNED", "未分配"),
LEGACY("LEGACY", "历史存量"),
FIRST("FIRST", "首单"),
CONTINUED("CONTINUED", "续单"),
NEUTRAL("NEUTRAL", "中性");
@com.baomidou.mybatisplus.annotation.EnumValue
@com.fasterxml.jackson.annotation.JsonValue
private final String code;
private final String description;
OrderRelationType(String code, String description) {
this.code = code;
this.description = description;
}
public static OrderRelationType fromCode(String code) {
for (OrderRelationType type : values()) {
if (type.code.equals(code)) {
return type;
}
}
throw new IllegalArgumentException("Unknown order relation type code: " + code);
}
}
/** /**
* 性别枚举 * 性别枚举
*/ */

View File

@@ -33,7 +33,8 @@ public class OrderCreationContext {
private OrderConstant.RewardType rewardType; private OrderConstant.RewardType rewardType;
private boolean isFirstOrder; @NotNull(message = "订单关系类型不能为空")
private OrderConstant.OrderRelationType orderRelationType;
private OrderConstant.PaymentSource paymentSource; private OrderConstant.PaymentSource paymentSource;
@@ -64,10 +65,6 @@ public class OrderCreationContext {
@Nullable @Nullable
private String creatorId; private String creatorId;
public String getFirstOrderString() {
return isFirstOrder ? "1" : "0";
}
public boolean isValidForRandomOrder() { public boolean isValidForRandomOrder() {
return placeType == OrderConstant.PlaceType.RANDOM && randomOrderRequirements != null; return placeType == OrderConstant.PlaceType.RANDOM && randomOrderRequirements != null;
} }

View File

@@ -0,0 +1,58 @@
package com.starry.admin.modules.order.module.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户-店员关系(完成历史快照)
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_clerk_customer_relation")
public class PlayClerkCustomerRelationEntity extends BaseEntity<PlayClerkCustomerRelationEntity> {
/**
* 主键
*/
private String id;
/**
* 顾客ID
*/
@TableField("purchaser_by")
private String purchaserBy;
/**
* 租户ID
*/
@TableField("tenant_id")
private String tenantId;
/**
* 店员ID
*/
@TableField("accept_by")
private String acceptBy;
/**
* 是否有已完成历史
*/
@TableField("has_completed")
private String hasCompleted;
/**
* 首次完成订单ID
*/
@TableField("first_completed_order_id")
private String firstCompletedOrderId;
/**
* 首次完成时间
*/
@TableField("first_completed_time")
private LocalDateTime firstCompletedTime;
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.admin.common.conf.StringTypeHandler; import com.starry.admin.common.conf.StringTypeHandler;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.service.impl.OrderLifecycleServiceImpl; import com.starry.admin.modules.order.service.impl.OrderLifecycleServiceImpl;
import com.starry.common.domain.BaseEntity; import com.starry.common.domain.BaseEntity;
@@ -64,8 +65,15 @@ public class PlayOrderInfoEntity extends BaseEntity<PlayOrderInfoEntity> {
/** /**
* 是否是首单【0不是1是】 * 是否是首单【0不是1是】
*/ */
@TableField(exist = false)
private String firstOrder; private String firstOrder;
/**
* 订单关系类型UNASSIGNED/FIRST/CONTINUED/NEUTRAL
*/
@TableField("order_relation_type")
private OrderConstant.OrderRelationType orderRelationType;
/** /**
* 退款类型【0未退款1已退款】 * 退款类型【0未退款1已退款】
*/ */
@@ -368,4 +376,14 @@ public class PlayOrderInfoEntity extends BaseEntity<PlayOrderInfoEntity> {
} }
this.orderEndTime = orderEndTime; this.orderEndTime = orderEndTime;
} }
public String getFirstOrder() {
if (orderRelationType == null) {
throw new IllegalStateException("订单关系类型不能为空");
}
if (orderRelationType == OrderConstant.OrderRelationType.CONTINUED) {
return OrderConstant.YesNoFlag.NO.getCode();
}
return OrderConstant.YesNoFlag.YES.getCode();
}
} }

View File

@@ -126,6 +126,11 @@ public class PlayOrderDetailsReturnVo {
*/ */
private String placeType; private String placeType;
/**
* 订单关系类型FIRST/CONTINUED/NEUTRAL
*/
private com.starry.admin.modules.order.module.constant.OrderConstant.OrderRelationType orderRelationType;
/** /**
* 要求店员性别0:未知;1:男;2:女)- 仅随机单有效 * 要求店员性别0:未知;1:男;2:女)- 仅随机单有效
*/ */

View File

@@ -140,6 +140,11 @@ public class PlayOrderInfoReturnVo {
*/ */
private String firstOrder; private String firstOrder;
/**
* 订单关系类型UNASSIGNED/FIRST/CONTINUED/NEUTRAL
*/
private com.starry.admin.modules.order.module.constant.OrderConstant.OrderRelationType orderRelationType;
/** /**
* 订单最终金额(支付金额) * 订单最终金额(支付金额)
*/ */

View File

@@ -2,6 +2,7 @@ package com.starry.admin.modules.order.service;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.dto.*; import com.starry.admin.modules.order.module.dto.*;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.*; import com.starry.admin.modules.order.module.vo.*;
@@ -51,39 +52,40 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
* @param clerkId 店员ID * @param clerkId 店员ID
* @param croupIds 优惠券ID列表 * @param croupIds 优惠券ID列表
* @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单) * @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单)
* @param firstOrder 是否是首单【0不是1是】 * @param relationType 订单关系类型
* @param orderAmount 订单金额 * @param orderAmount 订单金额
* @return com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo * @return com.starry.admin.modules.order.module.vo.ClerkEstimatedRevenueVo
* @author admin * @author admin
* @since 2024/7/18 16:39 * @since 2024/7/18 16:39
**/ **/
ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType, ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType,
String firstOrder, BigDecimal orderAmount); OrderConstant.OrderRelationType relationType, BigDecimal orderAmount);
/** /**
* 根据店员等级和订单金额,获取店员预计收入 * 根据店员等级和订单金额,获取店员预计收入
* *
* @param clerkId 店员ID * @param clerkId 店员ID
* @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单) * @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单)
* @param firstOrder 是否是首单【0不是1是】 * @param relationType 订单关系类型
* @param orderAmount 订单金额 * @param orderAmount 订单金额
* @return math.BigDecimal 店员预计收入 * @return math.BigDecimal 店员预计收入
* @author admin * @author admin
* @since 2024/6/3 11:12 * @since 2024/6/3 11:12
**/ **/
BigDecimal getEstimatedRevenue(String clerkId, String placeType, String firstOrder, BigDecimal orderAmount); BigDecimal getEstimatedRevenue(String clerkId, String placeType, OrderConstant.OrderRelationType relationType,
BigDecimal orderAmount);
/** /**
* 根据店员等级,获取店员提成比例 * 根据店员等级,获取店员提成比例
* *
* @param clerkId 店员ID * @param clerkId 店员ID
* @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单) * @param placeType 下单类型(-1:其他类型;0:指定单;1:随机单;2:打赏单)
* @param firstOrder 是否是首单【0不是1是】 * @param relationType 订单关系类型
* @return math.BigDecimal 店员预计收入 * @return math.BigDecimal 店员预计收入
* @author admin * @author admin
* @since 2024/6/3 11:12 * @since 2024/6/3 11:12
**/ **/
Integer getEstimatedRevenueRatio(String clerkId, String placeType, String firstOrder); Integer getEstimatedRevenueRatio(String clerkId, String placeType, OrderConstant.OrderRelationType relationType);
/** /**
* 根据订单结算状态查询订单 * 根据订单结算状态查询订单

View File

@@ -9,6 +9,7 @@ import com.starry.admin.common.exception.CustomException;
import com.starry.admin.common.exception.ServiceException; import com.starry.admin.common.exception.ServiceException;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayClerkCustomerRelationMapper;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderLogInfoMapper;
import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.constant.OrderConstant;
@@ -37,6 +38,7 @@ import com.starry.admin.modules.order.module.dto.OrderRefundContext;
import com.starry.admin.modules.order.module.dto.OrderRevocationContext; import com.starry.admin.modules.order.module.dto.OrderRevocationContext;
import com.starry.admin.modules.order.module.dto.PaymentInfo; import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements; import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayClerkCustomerRelationEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderLogInfoEntity;
import com.starry.admin.modules.order.module.event.OrderRevocationEvent; import com.starry.admin.modules.order.module.event.OrderRevocationEvent;
@@ -45,6 +47,7 @@ import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService; import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
import com.starry.admin.modules.pk.event.PkContributionEvent;
import com.starry.admin.modules.shop.module.constant.CouponUseState; import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
@@ -68,6 +71,7 @@ import javax.annotation.PostConstruct;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -112,6 +116,9 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
@Resource @Resource
private PlayOrderLogInfoMapper orderLogInfoMapper; private PlayOrderLogInfoMapper orderLogInfoMapper;
@Resource
private PlayClerkCustomerRelationMapper clerkCustomerRelationMapper;
@Resource @Resource
private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService; private IPlayBalanceDetailsInfoService playBalanceDetailsInfoService;
@@ -463,6 +470,9 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
if (latest == null) { if (latest == null) {
throw new CustomException("订单不存在"); throw new CustomException("订单不存在");
} }
if (OrderStatus.COMPLETED.getCode().equals(latest.getOrderStatus())) {
updateRelationOnCompletion(latest);
}
boolean forceNotify = context != null && context.isForceNotify(); boolean forceNotify = context != null && context.isForceNotify();
boolean earningsCreated = false; boolean earningsCreated = false;
@@ -495,6 +505,17 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
if (shouldNotify) { if (shouldNotify) {
notificationSender.sendOrderFinishMessageAsync(latest); notificationSender.sendOrderFinishMessageAsync(latest);
} }
if (OrderStatus.COMPLETED.getCode().equals(latest.getOrderStatus())
&& latest.getFinalAmount() != null
&& latest.getFinalAmount().compareTo(BigDecimal.ZERO) > 0
&& StrUtil.isNotBlank(latest.getAcceptBy())) {
LocalDateTime contributionTime =
latest.getOrderEndTime() != null ? latest.getOrderEndTime() : endTime;
applicationEventPublisher.publishEvent(
PkContributionEvent.orderContribution(latest.getId(), latest.getAcceptBy(),
latest.getFinalAmount(), contributionTime));
}
} }
@Override @Override
@@ -765,7 +786,16 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
entity.setOrderType(context.getOrderType().getCode()); entity.setOrderType(context.getOrderType().getCode());
entity.setPlaceType(context.getPlaceType().getCode()); entity.setPlaceType(context.getPlaceType().getCode());
entity.setRewardType(context.getRewardType().getCode()); entity.setRewardType(context.getRewardType().getCode());
entity.setFirstOrder(resolveFirstOrderFlag(context)); OrderConstant.OrderRelationType relationType = context.getOrderRelationType();
if (relationType == null) {
throw new CustomException("订单关系类型不能为空");
}
if (context.getPlaceType() == OrderConstant.PlaceType.RANDOM
&& relationType != OrderConstant.OrderRelationType.FIRST) {
throw new CustomException("随机单必须为首单关系");
}
entity.setOrderRelationType(relationType);
// entity.setFirstOrder(resolveFirstOrderFlag(context));
entity.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode()); entity.setRefundType(OrderRefundFlag.NOT_REFUNDED.getCode());
entity.setBackendEntry(YesNoFlag.NO.getCode()); entity.setBackendEntry(YesNoFlag.NO.getCode());
entity.setOrderSettlementState(OrderSettlementState.NOT_SETTLED.getCode()); entity.setOrderSettlementState(OrderSettlementState.NOT_SETTLED.getCode());
@@ -812,11 +842,17 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return; return;
} }
entity.setAcceptBy(context.getAcceptBy()); entity.setAcceptBy(context.getAcceptBy());
OrderConstant.OrderRelationType relationType = resolveOrderRelationType(
context.getPurchaserBy(),
context.getAcceptBy(),
context.getOrderId(),
context.getPlaceType());
entity.setOrderRelationType(relationType);
ClerkEstimatedRevenueVo estimatedRevenue = clerkRevenueCalculator.calculateEstimatedRevenue( ClerkEstimatedRevenueVo estimatedRevenue = clerkRevenueCalculator.calculateEstimatedRevenue(
context.getAcceptBy(), context.getAcceptBy(),
context.getPaymentInfo().getCouponIds(), context.getPaymentInfo().getCouponIds(),
context.getPlaceType().getCode(), context.getPlaceType().getCode(),
entity.getFirstOrder(), relationType,
context.getPaymentInfo().getOrderMoney()); context.getPaymentInfo().getOrderMoney());
entity.setEstimatedRevenue(estimatedRevenue.getRevenueAmount()); entity.setEstimatedRevenue(estimatedRevenue.getRevenueAmount());
entity.setEstimatedRevenueRatio(estimatedRevenue.getRevenueRatio()); entity.setEstimatedRevenueRatio(estimatedRevenue.getRevenueRatio());
@@ -855,21 +891,80 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
} }
} }
private String resolveFirstOrderFlag(OrderCreationContext context) { private OrderConstant.OrderRelationType resolveOrderRelationType(String customerId, String clerkId, String orderId,
if (StrUtil.isBlank(context.getAcceptBy()) || StrUtil.isBlank(context.getPurchaserBy())) { OrderConstant.PlaceType placeType) {
return context.getFirstOrderString(); if (StrUtil.isBlank(orderId)) {
throw new CustomException("订单关系计算缺少订单ID");
} }
return isFirstOrder(context.getPurchaserBy(), context.getAcceptBy()) if (placeType == OrderConstant.PlaceType.RANDOM) {
? YesNoFlag.YES.getCode() return OrderConstant.OrderRelationType.FIRST;
: YesNoFlag.NO.getCode(); }
if (StrUtil.isBlank(customerId) || StrUtil.isBlank(clerkId)) {
return OrderConstant.OrderRelationType.UNASSIGNED;
}
PlayClerkCustomerRelationEntity relation = ensureRelationForUpdate(customerId, clerkId);
if (relation == null) {
throw new CustomException("订单关系初始化失败");
}
if (StrUtil.isBlank(relation.getHasCompleted())) {
throw new CustomException("订单关系缺少完成标记");
}
if (OrderConstant.YesNoFlag.YES.getCode().equals(relation.getHasCompleted())) {
return OrderConstant.OrderRelationType.CONTINUED;
}
return OrderConstant.OrderRelationType.FIRST;
} }
private boolean isFirstOrder(String customerId, String clerkId) { private PlayClerkCustomerRelationEntity ensureRelationForUpdate(String customerId, String clerkId) {
LambdaQueryWrapper<PlayOrderInfoEntity> wrapper = Wrappers.lambdaQuery(PlayOrderInfoEntity.class) String tenantId = SecurityUtils.getTenantId();
.eq(PlayOrderInfoEntity::getPurchaserBy, customerId) if (StrUtil.isBlank(tenantId)) {
.eq(PlayOrderInfoEntity::getAcceptBy, clerkId) throw new CustomException("租户信息不能为空");
.eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode()); }
return orderInfoMapper.selectCount(wrapper) == 0; PlayClerkCustomerRelationEntity relation = clerkCustomerRelationMapper.selectForUpdate(tenantId, customerId, clerkId);
if (relation != null) {
return relation;
}
PlayClerkCustomerRelationEntity toCreate = new PlayClerkCustomerRelationEntity();
toCreate.setId(IdUtils.getUuid());
toCreate.setPurchaserBy(customerId);
toCreate.setTenantId(tenantId);
toCreate.setAcceptBy(clerkId);
toCreate.setHasCompleted(OrderConstant.YesNoFlag.NO.getCode());
toCreate.setDeleted(Boolean.FALSE);
try {
clerkCustomerRelationMapper.insert(toCreate);
} catch (DuplicateKeyException ex) {
log.debug("Relation already exists for customer {} and clerk {}", customerId, clerkId);
clerkCustomerRelationMapper.restoreRelation(tenantId, customerId, clerkId);
}
return clerkCustomerRelationMapper.selectForUpdate(tenantId, customerId, clerkId);
}
private void updateRelationOnCompletion(PlayOrderInfoEntity order) {
if (order == null) {
throw new CustomException("订单信息不能为空");
}
if (StrUtil.isBlank(order.getPurchaserBy()) || StrUtil.isBlank(order.getAcceptBy())) {
return;
}
PlayClerkCustomerRelationEntity relation = ensureRelationForUpdate(order.getPurchaserBy(), order.getAcceptBy());
if (relation == null) {
throw new CustomException("订单关系初始化失败");
}
if (OrderConstant.YesNoFlag.YES.getCode().equals(relation.getHasCompleted())
&& relation.getFirstCompletedTime() != null
&& StrUtil.isNotBlank(relation.getFirstCompletedOrderId())) {
return;
}
relation.setHasCompleted(OrderConstant.YesNoFlag.YES.getCode());
if (relation.getFirstCompletedTime() == null) {
LocalDateTime completionTime = order.getOrderEndTime() != null ? order.getOrderEndTime() : LocalDateTime.now();
relation.setFirstCompletedTime(completionTime);
}
if (StrUtil.isBlank(relation.getFirstCompletedOrderId())) {
relation.setFirstCompletedOrderId(order.getId());
}
clerkCustomerRelationMapper.updateById(relation);
} }
private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) { private boolean ensureEarnings(PlayOrderInfoEntity order, OrderTriggerSource source) {

View File

@@ -75,9 +75,9 @@ public class PlayOrderContinueInfoServiceImpl
entity.setReviewedTime(LocalDateTime.now()); entity.setReviewedTime(LocalDateTime.now());
this.baseMapper.updateById(entity); this.baseMapper.updateById(entity);
// 添加订单信息 // 添加订单信息
if ("1".equals(vo.getReviewState())) { // if ("1".equals(vo.getReviewState())) {
//
} // }
} }
@Override @Override

View File

@@ -18,6 +18,7 @@ import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayClerkCustomerRelationMapper;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType; import com.starry.admin.modules.order.module.constant.OrderConstant.BalanceOperationType;
@@ -30,6 +31,7 @@ import com.starry.admin.modules.order.module.constant.OrderConstant.OrderStatus;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource; import com.starry.admin.modules.order.module.constant.OrderConstant.OrderTriggerSource;
import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement; import com.starry.admin.modules.order.module.constant.OrderConstant.ReviewRequirement;
import com.starry.admin.modules.order.module.dto.*; import com.starry.admin.modules.order.module.dto.*;
import com.starry.admin.modules.order.module.entity.PlayClerkCustomerRelationEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
@@ -41,7 +43,9 @@ import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService; import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator; import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
import com.starry.admin.modules.personnel.service.IPlayPersonnelAdminInfoService;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
import com.starry.admin.modules.shop.module.constant.CouponUseState; import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
@@ -63,6 +67,7 @@ import java.util.Random;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -79,6 +84,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
IPlayOrderInfoService { IPlayOrderInfoService {
@Resource @Resource
private PlayOrderInfoMapper orderInfoMapper; private PlayOrderInfoMapper orderInfoMapper;
@Resource
private PlayClerkCustomerRelationMapper clerkCustomerRelationMapper;
@Resource @Resource
private IPlayClerkUserInfoService playClerkUserInfoService; private IPlayClerkUserInfoService playClerkUserInfoService;
@Resource @Resource
@@ -92,6 +100,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Resource @Resource
private IPlayPersonnelGroupInfoService playClerkGroupInfoService; private IPlayPersonnelGroupInfoService playClerkGroupInfoService;
@Resource
private IPlayPersonnelAdminInfoService playPersonnelAdminInfoService;
@Resource @Resource
private IPlayCouponDetailsService playCouponDetailsService; private IPlayCouponDetailsService playCouponDetailsService;
@@ -119,8 +130,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override @Override
public ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType, public ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType,
String firstOrder, BigDecimal orderAmount) { OrderConstant.OrderRelationType relationType,
return clerkRevenueCalculator.calculateEstimatedRevenue(clerkId, croupIds, placeType, firstOrder, orderAmount); BigDecimal orderAmount) {
return clerkRevenueCalculator.calculateEstimatedRevenue(clerkId, croupIds, placeType, relationType, orderAmount);
} }
/** /**
@@ -135,22 +147,22 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
* @since 2024/6/3 11:12 * @since 2024/6/3 11:12
**/ **/
@Override @Override
public BigDecimal getEstimatedRevenue(String clerkId, String placeType, String firstOrder, BigDecimal orderAmount) { public BigDecimal getEstimatedRevenue(String clerkId, String placeType,
OrderConstant.OrderRelationType relationType, BigDecimal orderAmount) {
PlayClerkLevelInfoEntity entity = playClerkUserInfoService.queryLevelCommission(clerkId); PlayClerkLevelInfoEntity entity = playClerkUserInfoService.queryLevelCommission(clerkId);
boolean isFirst = OrderConstant.YesNoFlag.YES.getCode().equals(firstOrder); boolean continued = ensureRelationTypeForRevenue(relationType);
BigDecimal safeOrderAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount; BigDecimal safeOrderAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount;
try { try {
OrderConstant.PlaceType place = OrderConstant.PlaceType.fromCode(placeType); OrderConstant.PlaceType place = OrderConstant.PlaceType.fromCode(placeType);
switch (place) { switch (place) {
case SPECIFIED: case SPECIFIED:
return calculateRevenue(safeOrderAmount, return calculateRevenue(safeOrderAmount,
isFirst ? entity.getFirstRegularRatio() : entity.getNotFirstRegularRatio()); continued ? entity.getNotFirstRegularRatio() : entity.getFirstRegularRatio());
case RANDOM: case RANDOM:
return calculateRevenue(safeOrderAmount, return calculateRevenue(safeOrderAmount, entity.getFirstRandomRadio());
isFirst ? entity.getFirstRandomRadio() : entity.getNotFirstRandomRadio());
case REWARD: case REWARD:
return calculateRevenue(safeOrderAmount, return calculateRevenue(safeOrderAmount,
isFirst ? entity.getFirstRewardRatio() : entity.getNotFirstRewardRatio()); continued ? entity.getNotFirstRewardRatio() : entity.getFirstRewardRatio());
case OTHER: case OTHER:
default: default:
log.error("下单类型异常placeType={}", placeType); log.error("下单类型异常placeType={}", placeType);
@@ -163,18 +175,19 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
} }
@Override @Override
public Integer getEstimatedRevenueRatio(String clerkId, String placeType, String firstOrder) { public Integer getEstimatedRevenueRatio(String clerkId, String placeType,
OrderConstant.OrderRelationType relationType) {
PlayClerkLevelInfoEntity entity = playClerkUserInfoService.queryLevelCommission(clerkId); PlayClerkLevelInfoEntity entity = playClerkUserInfoService.queryLevelCommission(clerkId);
boolean isFirst = OrderConstant.YesNoFlag.YES.getCode().equals(firstOrder); boolean continued = ensureRelationTypeForRevenue(relationType);
try { try {
OrderConstant.PlaceType place = OrderConstant.PlaceType.fromCode(placeType); OrderConstant.PlaceType place = OrderConstant.PlaceType.fromCode(placeType);
switch (place) { switch (place) {
case SPECIFIED: case SPECIFIED:
return isFirst ? entity.getFirstRegularRatio() : entity.getNotFirstRegularRatio(); return continued ? entity.getNotFirstRegularRatio() : entity.getFirstRegularRatio();
case RANDOM: case RANDOM:
return isFirst ? entity.getFirstRandomRadio() : entity.getNotFirstRandomRadio(); return entity.getFirstRandomRadio();
case REWARD: case REWARD:
return isFirst ? entity.getFirstRewardRatio() : entity.getNotFirstRewardRatio(); return continued ? entity.getNotFirstRewardRatio() : entity.getFirstRewardRatio();
case OTHER: case OTHER:
default: default:
log.error("下单类型异常placeType={}", placeType); log.error("下单类型异常placeType={}", placeType);
@@ -191,6 +204,19 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
.setScale(2, RoundingMode.HALF_UP); .setScale(2, RoundingMode.HALF_UP);
} }
private boolean ensureRelationTypeForRevenue(OrderConstant.OrderRelationType relationType) {
if (relationType == null) {
throw new CustomException("订单关系类型不能为空");
}
if (relationType == OrderConstant.OrderRelationType.UNASSIGNED) {
throw new CustomException("未分配订单不可计算预计收益");
}
if (relationType == OrderConstant.OrderRelationType.LEGACY) {
return false;
}
return relationType == OrderConstant.OrderRelationType.CONTINUED;
}
/** /**
* 新增充值订单 * 新增充值订单
* *
@@ -338,11 +364,94 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
@Override @Override
public Boolean checkFirstOrderFlag(String customId, String clerkId) { public Boolean checkFirstOrderFlag(String customId, String clerkId) {
LambdaQueryWrapper<PlayOrderInfoEntity> wrapper = Wrappers.lambdaQuery(PlayOrderInfoEntity.class) if (StringUtils.isBlank(customId) || StringUtils.isBlank(clerkId)) {
.eq(PlayOrderInfoEntity::getPurchaserBy, customId) throw new CustomException("用户或店员信息不能为空");
.eq(PlayOrderInfoEntity::getAcceptBy, clerkId) }
.eq(PlayOrderInfoEntity::getOrderStatus, OrderStatus.COMPLETED.getCode()); PlayClerkCustomerRelationEntity relation = clerkCustomerRelationMapper.selectOne(
return this.baseMapper.selectCount(wrapper) == 0; Wrappers.lambdaQuery(PlayClerkCustomerRelationEntity.class)
.eq(PlayClerkCustomerRelationEntity::getPurchaserBy, customId)
.eq(PlayClerkCustomerRelationEntity::getAcceptBy, clerkId)
.eq(PlayClerkCustomerRelationEntity::getDeleted, Boolean.FALSE));
if (relation == null) {
return Boolean.TRUE;
}
if (StringUtils.isBlank(relation.getHasCompleted())) {
throw new CustomException("订单关系缺少完成标记");
}
return !OrderConstant.YesNoFlag.YES.getCode().equals(relation.getHasCompleted());
}
private OrderConstant.OrderRelationType resolveOrderRelationType(String customId, String clerkId, String orderId,
String placeType) {
if (StringUtils.isBlank(orderId)) {
throw new CustomException("订单关系计算缺少订单ID");
}
if (OrderConstant.PlaceType.RANDOM.getCode().equals(placeType)) {
return OrderConstant.OrderRelationType.FIRST;
}
if (StringUtils.isBlank(customId) || StringUtils.isBlank(clerkId)) {
return OrderConstant.OrderRelationType.UNASSIGNED;
}
PlayClerkCustomerRelationEntity relation = ensureRelationForUpdate(customId, clerkId);
if (relation == null) {
throw new CustomException("订单关系初始化失败");
}
if (StringUtils.isBlank(relation.getHasCompleted())) {
throw new CustomException("订单关系缺少完成标记");
}
if (OrderConstant.YesNoFlag.YES.getCode().equals(relation.getHasCompleted())) {
return OrderConstant.OrderRelationType.CONTINUED;
}
return OrderConstant.OrderRelationType.FIRST;
}
private PlayClerkCustomerRelationEntity ensureRelationForUpdate(String customerId, String clerkId) {
String tenantId = SecurityUtils.getTenantId();
if (StringUtils.isBlank(tenantId)) {
throw new CustomException("租户信息不能为空");
}
PlayClerkCustomerRelationEntity relation = clerkCustomerRelationMapper.selectForUpdate(tenantId, customerId, clerkId);
if (relation != null) {
return relation;
}
PlayClerkCustomerRelationEntity toCreate = new PlayClerkCustomerRelationEntity();
toCreate.setId(IdUtils.getUuid());
toCreate.setPurchaserBy(customerId);
toCreate.setTenantId(tenantId);
toCreate.setAcceptBy(clerkId);
toCreate.setHasCompleted(OrderConstant.YesNoFlag.NO.getCode());
toCreate.setDeleted(Boolean.FALSE);
try {
clerkCustomerRelationMapper.insert(toCreate);
} catch (DuplicateKeyException ex) {
log.debug("Relation already exists for customer {} and clerk {}", customerId, clerkId);
clerkCustomerRelationMapper.restoreRelation(tenantId, customerId, clerkId);
}
return clerkCustomerRelationMapper.selectForUpdate(tenantId, customerId, clerkId);
}
private String mapFirstOrder(OrderConstant.OrderRelationType relationType) {
if (relationType == null) {
throw new CustomException("订单关系类型不能为空");
}
if (relationType == OrderConstant.OrderRelationType.CONTINUED) {
return OrderConstant.YesNoFlag.NO.getCode();
}
return OrderConstant.YesNoFlag.YES.getCode();
}
private OrderConstant.OrderRelationType normalizeRelationType(OrderConstant.OrderRelationType relationType,
String placeType) {
if (relationType == null) {
throw new CustomException("订单关系类型不能为空");
}
if (OrderConstant.PlaceType.RANDOM.getCode().equals(placeType)) {
return OrderConstant.OrderRelationType.FIRST;
}
if (relationType == OrderConstant.OrderRelationType.NEUTRAL) {
return OrderConstant.OrderRelationType.FIRST;
}
return relationType;
} }
/** /**
@@ -380,6 +489,12 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getId, orderId); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getId, orderId);
lambdaQueryWrapper.orderByDesc(PlayOrderInfoEntity::getPurchaserTime); lambdaQueryWrapper.orderByDesc(PlayOrderInfoEntity::getPurchaserTime);
PlayOrderDetailsReturnVo vo = this.baseMapper.selectJoinOne(PlayOrderDetailsReturnVo.class, lambdaQueryWrapper); PlayOrderDetailsReturnVo vo = this.baseMapper.selectJoinOne(PlayOrderDetailsReturnVo.class, lambdaQueryWrapper);
if (vo != null) {
OrderConstant.OrderRelationType relationType =
normalizeRelationType(vo.getOrderRelationType(), vo.getPlaceType());
vo.setOrderRelationType(relationType);
vo.setFirstOrder(mapFirstOrder(relationType));
}
// Privacy protection: Hide customer info for pending random orders // Privacy protection: Hide customer info for pending random orders
if (vo != null && OrderConstant.PlaceType.RANDOM.getCode().equals(vo.getPlaceType()) && OrderStatus.PENDING.getCode().equals(vo.getOrderStatus())) { if (vo != null && OrderConstant.PlaceType.RANDOM.getCode().equals(vo.getPlaceType()) && OrderStatus.PENDING.getCode().equals(vo.getOrderStatus())) {
@@ -434,7 +549,14 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getBackendEntry, vo.getBackendEntry()); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getBackendEntry, vo.getBackendEntry());
} }
if (StringUtils.isNotBlank(vo.getFirstOrder())) { if (StringUtils.isNotBlank(vo.getFirstOrder())) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getFirstOrder, vo.getFirstOrder()); String firstOrder = vo.getFirstOrder();
if (OrderConstant.YesNoFlag.YES.getCode().equals(firstOrder)) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderRelationType, OrderConstant.OrderRelationType.FIRST);
} else if (OrderConstant.YesNoFlag.NO.getCode().equals(firstOrder)) {
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getOrderRelationType, OrderConstant.OrderRelationType.CONTINUED);
} else {
throw new CustomException("首单筛选条件不合法");
}
} }
applyRangeFilter(lambdaQueryWrapper, vo.getPurchaserTime(), PlayOrderInfoEntity::getPurchaserTime); applyRangeFilter(lambdaQueryWrapper, vo.getPurchaserTime(), PlayOrderInfoEntity::getPurchaserTime);
applyRangeFilter(lambdaQueryWrapper, vo.getAcceptTime(), PlayOrderInfoEntity::getAcceptTime); applyRangeFilter(lambdaQueryWrapper, vo.getAcceptTime(), PlayOrderInfoEntity::getAcceptTime);
@@ -445,8 +567,17 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
vo.getClerkNickName()); vo.getClerkNickName());
lambdaQueryWrapper.and( lambdaQueryWrapper.and(
i -> i.isNull(PlayOrderInfoEntity::getAcceptBy).or().in(PlayOrderInfoEntity::getAcceptBy, clerkIdList)); i -> i.isNull(PlayOrderInfoEntity::getAcceptBy).or().in(PlayOrderInfoEntity::getAcceptBy, clerkIdList));
return this.baseMapper.selectJoinPage(new Page<>(vo.getPageNum(), vo.getPageSize()), IPage<PlayOrderInfoReturnVo> page = this.baseMapper.selectJoinPage(
PlayOrderInfoReturnVo.class, lambdaQueryWrapper); new Page<>(vo.getPageNum(), vo.getPageSize()),
PlayOrderInfoReturnVo.class,
lambdaQueryWrapper);
for (PlayOrderInfoReturnVo record : page.getRecords()) {
OrderConstant.OrderRelationType relationType =
normalizeRelationType(record.getOrderRelationType(), record.getPlaceType());
record.setOrderRelationType(relationType);
record.setFirstOrder(mapFirstOrder(relationType));
}
return page;
} }
@Override @Override
@@ -464,6 +595,10 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
if (returnVo == null) { if (returnVo == null) {
throw new CustomException("订单不存在或已失效"); throw new CustomException("订单不存在或已失效");
} }
OrderConstant.OrderRelationType relationType =
normalizeRelationType(returnVo.getOrderRelationType(), returnVo.getPlaceType());
returnVo.setOrderRelationType(relationType);
returnVo.setFirstOrder(mapFirstOrder(relationType));
// 如果订单状态为退款,查询订单退款原因 // 如果订单状态为退款,查询订单退款原因
if (OrderStatus.CANCELLED.getCode().equals(returnVo.getOrderStatus())) { if (OrderStatus.CANCELLED.getCode().equals(returnVo.getOrderStatus())) {
PlayOrderRefundInfoEntity orderRefundInfoEntity = playOrderRefundInfoService PlayOrderRefundInfoEntity orderRefundInfoEntity = playOrderRefundInfoService
@@ -540,6 +675,10 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
lambdaQueryWrapper); lambdaQueryWrapper);
for (PlayClerkOrderListReturnVo record : page.getRecords()) { for (PlayClerkOrderListReturnVo record : page.getRecords()) {
OrderConstant.OrderRelationType relationType =
normalizeRelationType(record.getOrderRelationType(), record.getPlaceType());
record.setOrderRelationType(relationType);
record.setFirstOrder(mapFirstOrder(relationType));
if (OrderConstant.PlaceType.RANDOM.getCode().equals(record.getPlaceType()) if (OrderConstant.PlaceType.RANDOM.getCode().equals(record.getPlaceType())
&& OrderStatus.PENDING.getCode().equals(record.getOrderStatus())) { && OrderStatus.PENDING.getCode().equals(record.getOrderStatus())) {
record.setCustomNickname("匿名用户"); record.setCustomNickname("匿名用户");
@@ -559,6 +698,12 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity, null); MPJLambdaWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = getCommonOrderQueryVo(entity, null);
PlayCustomOrderDetailsReturnVo returnVo = this.baseMapper.selectJoinOne(PlayCustomOrderDetailsReturnVo.class, PlayCustomOrderDetailsReturnVo returnVo = this.baseMapper.selectJoinOne(PlayCustomOrderDetailsReturnVo.class,
lambdaQueryWrapper); lambdaQueryWrapper);
if (returnVo != null) {
OrderConstant.OrderRelationType relationType =
normalizeRelationType(returnVo.getOrderRelationType(), returnVo.getPlaceType());
returnVo.setOrderRelationType(relationType);
returnVo.setFirstOrder(mapFirstOrder(relationType));
}
// 如果订单状态为退款,查询订单退款原因 // 如果订单状态为退款,查询订单退款原因
if (returnVo.getOrderStatus().equals(OrderStatus.CANCELLED.getCode())) { if (returnVo.getOrderStatus().equals(OrderStatus.CANCELLED.getCode())) {
PlayOrderRefundInfoEntity orderRefundInfoEntity = playOrderRefundInfoService PlayOrderRefundInfoEntity orderRefundInfoEntity = playOrderRefundInfoService
@@ -598,6 +743,10 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
PlayOrderEvaluateInfoEntity::getOrderId)); PlayOrderEvaluateInfoEntity::getOrderId));
for (PlayCustomOrderListReturnVo record : page.getRecords()) { for (PlayCustomOrderListReturnVo record : page.getRecords()) {
record.setEvaluate(evaluateInfos.containsKey(record.getId()) ? "1" : "0"); record.setEvaluate(evaluateInfos.containsKey(record.getId()) ? "1" : "0");
OrderConstant.OrderRelationType relationType =
normalizeRelationType(record.getOrderRelationType(), record.getPlaceType());
record.setOrderRelationType(relationType);
record.setFirstOrder(mapFirstOrder(relationType));
} }
// 获取当前顾客所有订单投诉信息,将订单评价信息转化为 map<订单ID订单ID>的结构 // 获取当前顾客所有订单投诉信息,将订单评价信息转化为 map<订单ID订单ID>的结构
PlayOrderComplaintInfoEntity orderComplaintInfoEntity = new PlayOrderComplaintInfoEntity(); PlayOrderComplaintInfoEntity orderComplaintInfoEntity = new PlayOrderComplaintInfoEntity();
@@ -627,6 +776,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
* @param orderId 订单Id * @param orderId 订单Id
**/ **/
@Override @Override
@Transactional(rollbackFor = Exception.class)
public void updateStateTo1(String operatorByType, String operatorBy, String acceptBy, String orderId) { public void updateStateTo1(String operatorByType, String operatorBy, String acceptBy, String orderId) {
boolean isClerkOperator = OrderConstant.OperatorType.CLERK.getCode().equals(operatorByType); boolean isClerkOperator = OrderConstant.OperatorType.CLERK.getCode().equals(operatorByType);
boolean isAdminOperator = OrderConstant.OperatorType.ADMIN.getCode().equals(operatorByType); boolean isAdminOperator = OrderConstant.OperatorType.ADMIN.getCode().equals(operatorByType);
@@ -658,18 +808,19 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
if (isClerkOperator && OrderConstant.PlaceType.RANDOM.getCode().equals(orderInfo.getPlaceType())) { if (isClerkOperator && OrderConstant.PlaceType.RANDOM.getCode().equals(orderInfo.getPlaceType())) {
validateClerkQualificationForRandomOrder(orderInfo, clerkUserInfoEntity, acceptBy); validateClerkQualificationForRandomOrder(orderInfo, clerkUserInfoEntity, acceptBy);
} }
String firstOrderFlag = resolveFirstOrderFlag(orderInfo.getPurchaserBy(), acceptBy); OrderConstant.OrderRelationType relationType =
resolveOrderRelationType(orderInfo.getPurchaserBy(), acceptBy, orderId, orderInfo.getPlaceType());
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(orderId, OrderStatus.ACCEPTED.getCode()); PlayOrderInfoEntity entity = new PlayOrderInfoEntity(orderId, OrderStatus.ACCEPTED.getCode());
LocalDateTime acceptTime = LocalDateTime.now(); LocalDateTime acceptTime = LocalDateTime.now();
entity.setAcceptBy(acceptBy); entity.setAcceptBy(acceptBy);
entity.setAcceptTime(acceptTime); entity.setAcceptTime(acceptTime);
entity.setFirstOrder(firstOrderFlag); entity.setOrderRelationType(relationType);
ClerkEstimatedRevenueVo estimatedRevenueVo = this.getClerkEstimatedRevenue( ClerkEstimatedRevenueVo estimatedRevenueVo = this.getClerkEstimatedRevenue(
acceptBy, acceptBy,
orderInfo.getCouponIds(), orderInfo.getCouponIds(),
orderInfo.getPlaceType(), orderInfo.getPlaceType(),
firstOrderFlag, relationType,
orderInfo.getOrderMoney()); orderInfo.getOrderMoney());
BigDecimal revenueAmount = estimatedRevenueVo.getRevenueAmount(); BigDecimal revenueAmount = estimatedRevenueVo.getRevenueAmount();
entity.setEstimatedRevenue(revenueAmount); entity.setEstimatedRevenue(revenueAmount);
@@ -703,7 +854,7 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
orderInfo.setOrderStatus(OrderStatus.ACCEPTED.getCode()); orderInfo.setOrderStatus(OrderStatus.ACCEPTED.getCode());
orderInfo.setEstimatedRevenue(revenueAmount); orderInfo.setEstimatedRevenue(revenueAmount);
orderInfo.setEstimatedRevenueRatio(estimatedRevenueVo.getRevenueRatio()); orderInfo.setEstimatedRevenueRatio(estimatedRevenueVo.getRevenueRatio());
orderInfo.setFirstOrder(firstOrderFlag); orderInfo.setOrderRelationType(relationType);
log.info("Order accepted successfully. orderId={}, orderNo={}, acceptBy={}, operatorByType={}", log.info("Order accepted successfully. orderId={}, orderNo={}, acceptBy={}, operatorByType={}",
orderId, orderInfo.getOrderNo(), acceptBy, operatorByType); orderId, orderInfo.getOrderNo(), acceptBy, operatorByType);
notificationSender.sendOrderMessageAsync(orderInfo); notificationSender.sendOrderMessageAsync(orderInfo);
@@ -729,18 +880,18 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
} }
} }
private String resolveFirstOrderFlag(String purchaserId, String clerkId) { // private String resolveFirstOrderFlag(String purchaserId, String clerkId) {
if (StrUtil.isBlank(purchaserId) || StrUtil.isBlank(clerkId)) { // if (StrUtil.isBlank(purchaserId) || StrUtil.isBlank(clerkId)) {
return OrderConstant.YesNoFlag.NO.getCode(); // return OrderConstant.YesNoFlag.NO.getCode();
} // }
Long completedCount = orderInfoMapper.selectCount(Wrappers.lambdaQuery(PlayOrderInfoEntity.class) // Long completedCount = orderInfoMapper.selectCount(Wrappers.lambdaQuery(PlayOrderInfoEntity.class)
.eq(PlayOrderInfoEntity::getPurchaserBy, purchaserId) // .eq(PlayOrderInfoEntity::getPurchaserBy, purchaserId)
.eq(PlayOrderInfoEntity::getAcceptBy, clerkId)); // .eq(PlayOrderInfoEntity::getAcceptBy, clerkId));
//
return (completedCount == null || completedCount == 0) // return (completedCount == null || completedCount == 0)
? OrderConstant.YesNoFlag.YES.getCode() // ? OrderConstant.YesNoFlag.YES.getCode()
: OrderConstant.YesNoFlag.NO.getCode(); // : OrderConstant.YesNoFlag.NO.getCode();
} // }
/** /**
* 获取通用的订单查询对象 订单作为主表 连接顾客用户表、店员用户表、商品表 * 获取通用的订单查询对象 订单作为主表 连接顾客用户表、店员用户表、商品表
@@ -911,7 +1062,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
throw new CustomException("只能操作本人订单"); throw new CustomException("只能操作本人订单");
} }
if ("1".equals(operatorByType) && !operatorBy.equals(orderInfo.getAcceptBy())) { if ("1".equals(operatorByType) && !operatorBy.equals(orderInfo.getAcceptBy())) {
throw new CustomException("只能操作本人订单"); if (!isClerkManagementOperator(operatorBy)) {
throw new CustomException("只能操作本人订单");
}
} }
// 取消订单(必须订单未接单或者为开始状态) // 取消订单(必须订单未接单或者为开始状态)
if (!orderInfo.getOrderStatus().equals(OrderStatus.PENDING.getCode()) if (!orderInfo.getOrderStatus().equals(OrderStatus.PENDING.getCode())
@@ -933,6 +1086,22 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
notificationSender.sendOrderCancelMessageAsync(orderInfo, refundReason); notificationSender.sendOrderCancelMessageAsync(orderInfo, refundReason);
} }
private boolean isClerkManagementOperator(String clerkId) {
if (StringUtils.isBlank(clerkId)) {
return false;
}
PlayClerkUserInfoEntity clerkInfo = playClerkUserInfoService.selectById(clerkId);
if (clerkInfo == null || StringUtils.isBlank(clerkInfo.getSysUserId())) {
return false;
}
PlayPersonnelAdminInfoEntity adminInfo = playPersonnelAdminInfoService.selectByUserId(clerkInfo.getSysUserId());
if (adminInfo != null) {
return true;
}
PlayPersonnelGroupInfoEntity groupInfo = playClerkGroupInfoService.selectByUserId(clerkInfo.getSysUserId());
return groupInfo != null;
}
/** /**
* 已接单/服务中的订单强制取消,仅允许店员本人或管理员操作 * 已接单/服务中的订单强制取消,仅允许店员本人或管理员操作
*/ */
@@ -941,7 +1110,9 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public void forceCancelOngoingOrder(String operatorByType, String operatorBy, String orderId, BigDecimal refundAmount, public void forceCancelOngoingOrder(String operatorByType, String operatorBy, String orderId, BigDecimal refundAmount,
String refundReason, List<String> images) { String refundReason, List<String> images) {
if (!"2".equals(operatorByType)) { if (!"2".equals(operatorByType)) {
throw new CustomException("禁止操作"); if (!("1".equals(operatorByType) && isClerkManagementOperator(operatorBy))) {
throw new CustomException("禁止操作");
}
} }
PlayOrderInfoEntity orderInfo = this.selectOrderInfoById(orderId); PlayOrderInfoEntity orderInfo = this.selectOrderInfoById(orderId);
if (!OrderStatus.ACCEPTED.getCode().equals(orderInfo.getOrderStatus()) if (!OrderStatus.ACCEPTED.getCode().equals(orderInfo.getOrderStatus())

View File

@@ -31,7 +31,7 @@ public class ClerkRevenueCalculator {
String clerkId, String clerkId,
List<String> couponIds, List<String> couponIds,
String placeType, String placeType,
String firstOrder, OrderConstant.OrderRelationType relationType,
BigDecimal orderAmount) { BigDecimal orderAmount) {
PlayClerkLevelInfoEntity levelInfo = playClerkUserInfoService.queryLevelCommission(clerkId); PlayClerkLevelInfoEntity levelInfo = playClerkUserInfoService.queryLevelCommission(clerkId);
BigDecimal baseAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount; BigDecimal baseAmount = orderAmount == null ? BigDecimal.ZERO : orderAmount;
@@ -56,13 +56,13 @@ public class ClerkRevenueCalculator {
switch (placeTypeEnum) { switch (placeTypeEnum) {
case SPECIFIED: // 指定单 case SPECIFIED: // 指定单
fillRegularOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRegularOrderRevenue(clerkId, relationType, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case RANDOM: // 随机单 case RANDOM: // 随机单
fillRandomOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRandomOrderRevenue(clerkId, relationType, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case REWARD: // 打赏单 case REWARD: // 打赏单
fillRewardOrderRevenue(clerkId, firstOrder, baseAmount, levelInfo, estimatedRevenueVo); fillRewardOrderRevenue(clerkId, relationType, baseAmount, levelInfo, estimatedRevenueVo);
break; break;
case OTHER: case OTHER:
default: default:
@@ -78,9 +78,11 @@ public class ClerkRevenueCalculator {
return estimatedRevenueVo; return estimatedRevenueVo;
} }
private void fillRegularOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount, private void fillRegularOrderRevenue(String clerkId, OrderConstant.OrderRelationType relationType,
BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { boolean continued = validateRelationType(relationType);
if (!continued) {
int ratio = safeRatio(levelInfo.getFirstRegularRatio(), "firstRegularRatio", clerkId); int ratio = safeRatio(levelInfo.getFirstRegularRatio(), "firstRegularRatio", clerkId);
vo.setRevenueRatio(ratio); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio)); vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
@@ -91,22 +93,19 @@ public class ClerkRevenueCalculator {
} }
} }
private void fillRandomOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount, private void fillRandomOrderRevenue(String clerkId, OrderConstant.OrderRelationType relationType,
BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { int ratio = safeRatio(levelInfo.getFirstRandomRadio(), "firstRandomRatio", clerkId);
int ratio = safeRatio(levelInfo.getFirstRandomRadio(), "firstRandomRatio", clerkId); vo.setRevenueRatio(ratio);
vo.setRevenueRatio(ratio); vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
} else {
int ratio = safeRatio(levelInfo.getNotFirstRandomRadio(), "notFirstRandomRatio", clerkId);
vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
}
} }
private void fillRewardOrderRevenue(String clerkId, String firstOrder, BigDecimal orderAmount, private void fillRewardOrderRevenue(String clerkId, OrderConstant.OrderRelationType relationType,
BigDecimal orderAmount,
PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) { PlayClerkLevelInfoEntity levelInfo, ClerkEstimatedRevenueVo vo) {
if ("1".equals(firstOrder)) { boolean continued = validateRelationType(relationType);
if (!continued) {
int ratio = safeRatio(levelInfo.getFirstRewardRatio(), "firstRewardRatio", clerkId); int ratio = safeRatio(levelInfo.getFirstRewardRatio(), "firstRewardRatio", clerkId);
vo.setRevenueRatio(ratio); vo.setRevenueRatio(ratio);
vo.setRevenueAmount(scaleAmount(orderAmount, ratio)); vo.setRevenueAmount(scaleAmount(orderAmount, ratio));
@@ -125,6 +124,19 @@ public class ClerkRevenueCalculator {
return ratio; return ratio;
} }
private boolean validateRelationType(OrderConstant.OrderRelationType relationType) {
if (relationType == null) {
throw new IllegalArgumentException("订单关系类型不能为空");
}
if (relationType == OrderConstant.OrderRelationType.UNASSIGNED) {
throw new IllegalArgumentException("未分配订单不可计算预计收益");
}
if (relationType == OrderConstant.OrderRelationType.LEGACY) {
return false;
}
return relationType == OrderConstant.OrderRelationType.CONTINUED;
}
private BigDecimal scaleAmount(BigDecimal baseAmount, int ratio) { private BigDecimal scaleAmount(BigDecimal baseAmount, int ratio) {
return baseAmount return baseAmount
.multiply(BigDecimal.valueOf(ratio).divide(new BigDecimal(100), 4, RoundingMode.HALF_UP)) .multiply(BigDecimal.valueOf(ratio).divide(new BigDecimal(100), 4, RoundingMode.HALF_UP))

View File

@@ -0,0 +1,24 @@
package com.starry.admin.modules.pk.constants;
import java.math.BigDecimal;
public final class PkWxQueryConstants {
public static final int CONTRIBUTOR_LIMIT = 10;
public static final int HISTORY_LIMIT = 10;
public static final int CLERK_HISTORY_PAGE_NUM = 1;
public static final int CLERK_HISTORY_PAGE_SIZE = 10;
public static final int CLERK_HISTORY_MIN_PAGE = 1;
public static final int CLERK_SCHEDULE_DEFAULT_LIMIT = 3;
public static final int CLERK_SCHEDULE_MIN_LIMIT = 1;
public static final int CLERK_SCHEDULE_MAX_LIMIT = 20;
public static final int TOP_CONTRIBUTOR_LIMIT = 1;
public static final int WIN_RATE_SCALE = 2;
public static final String PERCENT_SUFFIX = "%";
public static final BigDecimal MIN_CONTRIBUTION_AMOUNT = BigDecimal.ZERO;
public static final BigDecimal WIN_RATE_MULTIPLIER = new BigDecimal("100");
private PkWxQueryConstants() {
throw new IllegalStateException("Utility class");
}
}

View File

@@ -0,0 +1,53 @@
package com.starry.admin.modules.pk.dto;
/**
* 通过 WebSocket 推送的 PK 事件消息。
*/
public class PkEventMessage<T> {
private PkEventType type;
private String pkId;
private long timestamp;
private T payload;
public static <T> PkEventMessage<T> of(PkEventType type, String pkId, T payload, long timestamp) {
PkEventMessage<T> message = new PkEventMessage<>();
message.setType(type);
message.setPkId(pkId);
message.setPayload(payload);
message.setTimestamp(timestamp);
return message;
}
public PkEventType getType() {
return type;
}
public void setType(PkEventType type) {
this.type = type;
}
public String getPkId() {
return pkId;
}
public void setPkId(String pkId) {
this.pkId = pkId;
}
public long getTimestamp() {
return timestamp;
}
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
public T getPayload() {
return payload;
}
public void setPayload(T payload) {
this.payload = payload;
}
}

View File

@@ -0,0 +1,9 @@
package com.starry.admin.modules.pk.dto;
/**
* PK WebSocket 事件类型。
*/
public enum PkEventType {
SCORE_UPDATE,
STATE_CHANGE
}

View File

@@ -0,0 +1,97 @@
package com.starry.admin.modules.pk.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.math.BigDecimal;
@ApiModel(value = "PkScoreBoardDto", description = "店员 PK 实时比分")
public class PkScoreBoardDto {
@ApiModelProperty(value = "PK ID", required = true)
private String pkId;
@ApiModelProperty(value = "店员A ID", required = true)
private String clerkAId;
@ApiModelProperty(value = "店员B ID", required = true)
private String clerkBId;
@ApiModelProperty(value = "店员A得分", required = true)
private BigDecimal clerkAScore;
@ApiModelProperty(value = "店员B得分", required = true)
private BigDecimal clerkBScore;
@ApiModelProperty(value = "店员A订单数", required = true)
private int clerkAOrderCount;
@ApiModelProperty(value = "店员B订单数", required = true)
private int clerkBOrderCount;
@ApiModelProperty(value = "PK 剩余秒数", required = true)
private long remainingSeconds;
public String getPkId() {
return pkId;
}
public void setPkId(String pkId) {
this.pkId = pkId;
}
public String getClerkAId() {
return clerkAId;
}
public void setClerkAId(String clerkAId) {
this.clerkAId = clerkAId;
}
public String getClerkBId() {
return clerkBId;
}
public void setClerkBId(String clerkBId) {
this.clerkBId = clerkBId;
}
public BigDecimal getClerkAScore() {
return clerkAScore;
}
public void setClerkAScore(BigDecimal clerkAScore) {
this.clerkAScore = clerkAScore;
}
public BigDecimal getClerkBScore() {
return clerkBScore;
}
public void setClerkBScore(BigDecimal clerkBScore) {
this.clerkBScore = clerkBScore;
}
public int getClerkAOrderCount() {
return clerkAOrderCount;
}
public void setClerkAOrderCount(int clerkAOrderCount) {
this.clerkAOrderCount = clerkAOrderCount;
}
public int getClerkBOrderCount() {
return clerkBOrderCount;
}
public void setClerkBOrderCount(int clerkBOrderCount) {
this.clerkBOrderCount = clerkBOrderCount;
}
public long getRemainingSeconds() {
return remainingSeconds;
}
public void setRemainingSeconds(long remainingSeconds) {
this.remainingSeconds = remainingSeconds;
}
}

View File

@@ -0,0 +1,19 @@
package com.starry.admin.modules.pk.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@ApiModel(value = "PlayClerkPkForceStartRequest", description = "强制开始PK请求")
@Data
public class PlayClerkPkForceStartRequest {
@ApiModelProperty(value = "店员A ID", required = true)
private String clerkAId;
@ApiModelProperty(value = "店员B ID", required = true)
private String clerkBId;
@ApiModelProperty(value = "持续分钟数", required = true)
private Integer durationMinutes;
}

View File

@@ -0,0 +1,14 @@
package com.starry.admin.modules.pk.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
@Data
public class WxPkClerkHistoryPageDto {
private List<WxPkHistoryDto> items = new ArrayList<>();
private WxPkClerkHistorySummaryDto summary = new WxPkClerkHistorySummaryDto();
private long totalCount;
private int pageNum;
private int pageSize;
}

View File

@@ -0,0 +1,12 @@
package com.starry.admin.modules.pk.dto;
import lombok.Data;
@Data
public class WxPkClerkHistorySummaryDto {
private static final String ZERO_PERCENT = "0%";
private long winCount;
private long totalCount;
private String winRate = ZERO_PERCENT;
}

View File

@@ -0,0 +1,13 @@
package com.starry.admin.modules.pk.dto;
import java.math.BigDecimal;
import lombok.Data;
@Data
public class WxPkContributorDto {
private static final String EMPTY_TEXT = "";
private String userId = EMPTY_TEXT;
private String nickname = EMPTY_TEXT;
private BigDecimal amount = BigDecimal.ZERO;
}

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.pk.dto;
import com.starry.admin.modules.pk.enums.PkWxState;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import lombok.Data;
@Data
public class WxPkDetailDto {
private static final String EMPTY_TEXT = "";
private static final long ZERO_SECONDS = 0L;
private static final Date EPOCH_DATE = Date.from(Instant.EPOCH);
private String id = EMPTY_TEXT;
private String state = EMPTY_TEXT;
private String clerkAId = EMPTY_TEXT;
private String clerkBId = EMPTY_TEXT;
private String clerkAName = EMPTY_TEXT;
private String clerkBName = EMPTY_TEXT;
private String clerkAAvatar = EMPTY_TEXT;
private String clerkBAvatar = EMPTY_TEXT;
private BigDecimal clerkAScore = BigDecimal.ZERO;
private BigDecimal clerkBScore = BigDecimal.ZERO;
private int clerkAOrderCount = 0;
private int clerkBOrderCount = 0;
private long remainingSeconds = ZERO_SECONDS;
private Date pkBeginTime = EPOCH_DATE;
private Date pkEndTime = EPOCH_DATE;
private List<WxPkContributorDto> contributors = new ArrayList<>();
private List<WxPkHistoryDto> history = new ArrayList<>();
public static WxPkDetailDto inactive() {
WxPkDetailDto dto = new WxPkDetailDto();
dto.setState(PkWxState.INACTIVE.getValue());
return dto;
}
}

View File

@@ -0,0 +1,24 @@
package com.starry.admin.modules.pk.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Date;
import lombok.Data;
@Data
public class WxPkHistoryDto {
private static final String EMPTY_TEXT = "";
private static final Date EPOCH_DATE = Date.from(Instant.EPOCH);
private String id = EMPTY_TEXT;
private String clerkAId = EMPTY_TEXT;
private String clerkBId = EMPTY_TEXT;
private String winnerClerkId = EMPTY_TEXT;
private String clerkAName = EMPTY_TEXT;
private String clerkBName = EMPTY_TEXT;
private BigDecimal clerkAScore = BigDecimal.ZERO;
private BigDecimal clerkBScore = BigDecimal.ZERO;
private Date pkBeginTime = EPOCH_DATE;
private String topContributorName = EMPTY_TEXT;
private BigDecimal topContributorAmount = BigDecimal.ZERO;
}

View File

@@ -0,0 +1,33 @@
package com.starry.admin.modules.pk.dto;
import com.starry.admin.modules.pk.enums.PkWxState;
import java.math.BigDecimal;
import lombok.Data;
@Data
public class WxPkLiveDto {
private static final String EMPTY_TEXT = "";
private static final long ZERO_SECONDS = 0L;
private String id = EMPTY_TEXT;
private String state = EMPTY_TEXT;
private String clerkAId = EMPTY_TEXT;
private String clerkBId = EMPTY_TEXT;
private String clerkAName = EMPTY_TEXT;
private String clerkBName = EMPTY_TEXT;
private String clerkAAvatar = EMPTY_TEXT;
private String clerkBAvatar = EMPTY_TEXT;
private BigDecimal clerkAScore = BigDecimal.ZERO;
private BigDecimal clerkBScore = BigDecimal.ZERO;
private int clerkAOrderCount = 0;
private int clerkBOrderCount = 0;
private long remainingSeconds = ZERO_SECONDS;
private long pkEndEpochSeconds = ZERO_SECONDS;
private long serverEpochSeconds = ZERO_SECONDS;
public static WxPkLiveDto inactive() {
WxPkLiveDto dto = new WxPkLiveDto();
dto.setState(PkWxState.INACTIVE.getValue());
return dto;
}
}

View File

@@ -0,0 +1,28 @@
package com.starry.admin.modules.pk.dto;
import com.starry.admin.modules.pk.enums.PkWxState;
import java.time.Instant;
import java.util.Date;
import lombok.Data;
@Data
public class WxPkUpcomingDto {
private static final String EMPTY_TEXT = "";
private static final Date EPOCH_DATE = Date.from(Instant.EPOCH);
private String id = EMPTY_TEXT;
private String state = EMPTY_TEXT;
private String clerkAId = EMPTY_TEXT;
private String clerkBId = EMPTY_TEXT;
private String clerkAName = EMPTY_TEXT;
private String clerkBName = EMPTY_TEXT;
private String clerkAAvatar = EMPTY_TEXT;
private String clerkBAvatar = EMPTY_TEXT;
private Date pkBeginTime = EPOCH_DATE;
public static WxPkUpcomingDto inactive() {
WxPkUpcomingDto dto = new WxPkUpcomingDto();
dto.setState(PkWxState.INACTIVE.getValue());
return dto;
}
}

View File

@@ -0,0 +1,17 @@
package com.starry.admin.modules.pk.enums;
public enum PkLifecycleErrorCode {
REQUEST_INVALID("PK手动开始参数非法"),
CLERK_CONFLICT("店员排期冲突"),
TENANT_MISSING("租户ID缺失");
private final String message;
PkLifecycleErrorCode(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}

View File

@@ -0,0 +1,16 @@
package com.starry.admin.modules.pk.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
public enum PkWxState {
ACTIVE("ACTIVE", "进行中"),
UPCOMING("UPCOMING", "即将开始"),
INACTIVE("INACTIVE", "无进行中PK");
@Getter
private final String value;
@Getter
private final String desc;
}

View File

@@ -0,0 +1,74 @@
package com.starry.admin.modules.pk.event;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 店员在 PK 期间产生的一次贡献事件。
*/
public final class PkContributionEvent {
/**
* 贡献来源的业务唯一ID例如订单ID、礼物记录ID等。
*/
private final String referenceId;
private final String clerkId;
private final BigDecimal amount;
private final PkContributionSource source;
private final LocalDateTime occurredAt;
private PkContributionEvent(String referenceId, String clerkId, BigDecimal amount, PkContributionSource source,
LocalDateTime occurredAt) {
this.referenceId = Objects.requireNonNull(referenceId, "referenceId cannot be null");
this.clerkId = Objects.requireNonNull(clerkId, "clerkId cannot be null");
this.amount = Objects.requireNonNull(amount, "amount cannot be null");
this.source = Objects.requireNonNull(source, "source cannot be null");
this.occurredAt = Objects.requireNonNull(occurredAt, "occurredAt cannot be null");
if (this.referenceId.isEmpty()) {
throw new IllegalArgumentException("referenceId cannot be empty");
}
if (this.clerkId.isEmpty()) {
throw new IllegalArgumentException("clerkId cannot be empty");
}
if (this.amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("amount cannot be negative");
}
}
public static PkContributionEvent orderContribution(String orderId, String clerkId, BigDecimal amount,
LocalDateTime occurredAt) {
return new PkContributionEvent(orderId, clerkId, amount, PkContributionSource.ORDER, occurredAt);
}
public static PkContributionEvent giftContribution(String giftRecordId, String clerkId, BigDecimal amount,
LocalDateTime occurredAt) {
return new PkContributionEvent(giftRecordId, clerkId, amount, PkContributionSource.GIFT, occurredAt);
}
public static PkContributionEvent rechargeContribution(String rechargeRecordId, String clerkId, BigDecimal amount,
LocalDateTime occurredAt) {
return new PkContributionEvent(rechargeRecordId, clerkId, amount, PkContributionSource.RECHARGE, occurredAt);
}
public String getReferenceId() {
return referenceId;
}
public String getClerkId() {
return clerkId;
}
public BigDecimal getAmount() {
return amount;
}
public PkContributionSource getSource() {
return source;
}
public LocalDateTime getOccurredAt() {
return occurredAt;
}
}

View File

@@ -0,0 +1,10 @@
package com.starry.admin.modules.pk.event;
/**
* PK 贡献来源类型。
*/
public enum PkContributionSource {
ORDER,
GIFT,
RECHARGE
}

View File

@@ -0,0 +1,22 @@
package com.starry.admin.modules.pk.event;
import java.util.Objects;
/**
* 某一场 PK 的比分发生变化。
*/
public final class PkScoreChangedEvent {
private final String pkId;
public PkScoreChangedEvent(String pkId) {
this.pkId = Objects.requireNonNull(pkId, "pkId cannot be null");
if (this.pkId.isEmpty()) {
throw new IllegalArgumentException("pkId cannot be empty");
}
}
public String getPkId() {
return pkId;
}
}

View File

@@ -0,0 +1,71 @@
package com.starry.admin.modules.pk.listener;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.pk.event.PkContributionEvent;
import com.starry.admin.modules.pk.event.PkScoreChangedEvent;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
/**
* 监听订单/礼物/充值产生的 PK 贡献事件,更新 Redis 中的比分。
*/
@Component
public class PkContributionListener {
private static final long SINGLE_CONTRIBUTION_COUNT = 1L;
@Resource
private IPlayClerkPkService clerkPkService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
@EventListener
public void onContribution(PkContributionEvent event) {
if (!acquireDedup(event)) {
return;
}
Optional<PlayClerkPkEntity> pkOptional =
clerkPkService.findActivePkForClerk(event.getClerkId(), event.getOccurredAt());
if (!pkOptional.isPresent()) {
return;
}
PlayClerkPkEntity pk = pkOptional.get();
updateRedisScore(pk, event);
applicationEventPublisher.publishEvent(new PkScoreChangedEvent(pk.getId()));
}
private boolean acquireDedup(PkContributionEvent event) {
String dedupKey =
PkRedisKeyConstants.contributionDedupKey(event.getSource().name(), event.getReferenceId());
Boolean firstSeen = stringRedisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", PkRedisKeyConstants.CONTRIBUTION_DEDUP_TTL_SECONDS, TimeUnit.SECONDS);
return firstSeen == null || firstSeen;
}
private void updateRedisScore(PlayClerkPkEntity pk, PkContributionEvent event) {
String pkId = pk.getId();
if (pkId == null || pkId.isEmpty()) {
return;
}
boolean isClerkA = event.getClerkId().equals(pk.getClerkA());
String scoreField = isClerkA ? PkRedisKeyConstants.FIELD_CLERK_A_SCORE : PkRedisKeyConstants.FIELD_CLERK_B_SCORE;
String countField =
isClerkA ? PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT : PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT;
String scoreKey = PkRedisKeyConstants.scoreKey(pkId);
stringRedisTemplate.opsForHash().increment(scoreKey, scoreField, event.getAmount().doubleValue());
stringRedisTemplate.opsForHash().increment(scoreKey, countField, SINGLE_CONTRIBUTION_COUNT);
}
}

View File

@@ -0,0 +1,37 @@
package com.starry.admin.modules.pk.listener;
import com.starry.admin.modules.pk.dto.PkEventMessage;
import com.starry.admin.modules.pk.dto.PkEventType;
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
import com.starry.admin.modules.pk.event.PkScoreChangedEvent;
import com.starry.admin.modules.pk.service.IPkScoreboardService;
import javax.annotation.Resource;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
/**
* 监听比分变化事件,通过 WebSocket 将最新比分推送给订阅方。
*/
@Component
public class PkScoreChangedListener {
private static final String PK_TOPIC_PREFIX = "/topic/pk/";
@Resource
private IPkScoreboardService pkScoreboardService;
@Resource
private SimpMessagingTemplate messagingTemplate;
@EventListener
public void onScoreChanged(PkScoreChangedEvent event) {
PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(event.getPkId());
PkEventMessage<PkScoreBoardDto> message = PkEventMessage.of(
PkEventType.SCORE_UPDATE,
event.getPkId(),
scoreboard,
System.currentTimeMillis());
messagingTemplate.convertAndSend(PK_TOPIC_PREFIX + event.getPkId(), message);
}
}

View File

@@ -0,0 +1,68 @@
package com.starry.admin.modules.pk.redis;
/**
* PK 相关 Redis key 常量。
*/
public final class PkRedisKeyConstants {
private static final String SCORE_HASH_PREFIX = "pk:";
private static final String SCORE_HASH_SUFFIX = ":score";
private static final String DEDUP_KEY_PREFIX = "pk:dedup:";
private static final String UPCOMING_PREFIX = "pk:upcoming:";
private static final String START_SCHEDULE_PREFIX = "pk:scheduler:start:";
private static final String START_LOCK_PREFIX = "pk:scheduler:start:lock:";
private static final String FINISH_SCHEDULE_PREFIX = "pk:scheduler:finish:";
private static final String FINISH_RETRY_PREFIX = "pk:scheduler:finish:retry:";
private static final String FINISH_FAILED_PREFIX = "pk:scheduler:finish:failed:";
private static final String FINISH_LOCK_PREFIX = "pk:scheduler:finish:lock:";
/**
* 贡献幂等记录的存活时间(秒)。
*/
public static final long CONTRIBUTION_DEDUP_TTL_SECONDS = 3600L;
public static final long UPCOMING_REMINDER_TTL_SECONDS = 7200L;
public static final String FIELD_CLERK_A_SCORE = "clerk_a_score";
public static final String FIELD_CLERK_B_SCORE = "clerk_b_score";
public static final String FIELD_CLERK_A_ORDER_COUNT = "clerk_a_order_count";
public static final String FIELD_CLERK_B_ORDER_COUNT = "clerk_b_order_count";
private PkRedisKeyConstants() {
}
public static String scoreKey(String pkId) {
return SCORE_HASH_PREFIX + pkId + SCORE_HASH_SUFFIX;
}
public static String contributionDedupKey(String sourceCode, String referenceId) {
return DEDUP_KEY_PREFIX + sourceCode + ":" + referenceId;
}
public static String upcomingKey(String tenantId) {
return UPCOMING_PREFIX + tenantId;
}
public static String startScheduleKey(String tenantId) {
return START_SCHEDULE_PREFIX + tenantId;
}
public static String startLockKey(String tenantId) {
return START_LOCK_PREFIX + tenantId;
}
public static String finishScheduleKey(String tenantId) {
return FINISH_SCHEDULE_PREFIX + tenantId;
}
public static String finishRetryKey(String tenantId) {
return FINISH_RETRY_PREFIX + tenantId;
}
public static String finishFailedKey(String tenantId) {
return FINISH_FAILED_PREFIX + tenantId;
}
public static String finishLockKey(String tenantId) {
return FINISH_LOCK_PREFIX + tenantId;
}
}

View File

@@ -0,0 +1,10 @@
package com.starry.admin.modules.pk.reminder.constants;
public final class PkReminderConstants {
public static final long SCAN_INTERVAL_MILLIS = 300000L;
public static final long UPCOMING_WINDOW_MINUTES = 60L;
private PkReminderConstants() {
}
}

View File

@@ -0,0 +1,64 @@
package com.starry.admin.modules.pk.reminder.task;
import cn.hutool.core.util.StrUtil;
import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper;
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import com.starry.admin.modules.pk.reminder.constants.PkReminderConstants;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class ClerkPkUpcomingReminderJob {
private static final double SCORE_MIN = Double.NEGATIVE_INFINITY;
private final PlayClerkPkMapper clerkPkMapper;
private final StringRedisTemplate stringRedisTemplate;
public ClerkPkUpcomingReminderJob(PlayClerkPkMapper clerkPkMapper, StringRedisTemplate stringRedisTemplate) {
this.clerkPkMapper = clerkPkMapper;
this.stringRedisTemplate = stringRedisTemplate;
}
@Scheduled(fixedDelay = PkReminderConstants.SCAN_INTERVAL_MILLIS)
public void refreshUpcomingPkReminders() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime windowEnd = now.plusMinutes(PkReminderConstants.UPCOMING_WINDOW_MINUTES);
Date begin = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
Date end = Date.from(windowEnd.atZone(ZoneId.systemDefault()).toInstant());
List<PlayClerkPkEntity> upcoming = clerkPkMapper.selectUpcomingByStatus(
ClerkPkEnum.TO_BE_STARTED.name(),
begin,
end);
if (upcoming.isEmpty()) {
return;
}
Map<String, List<PlayClerkPkEntity>> byTenant = upcoming.stream()
.filter(pk -> StrUtil.isNotBlank(pk.getTenantId()))
.collect(Collectors.groupingBy(PlayClerkPkEntity::getTenantId));
long nowEpochSeconds = now.atZone(ZoneId.systemDefault()).toEpochSecond();
for (Map.Entry<String, List<PlayClerkPkEntity>> entry : byTenant.entrySet()) {
String key = PkRedisKeyConstants.upcomingKey(entry.getKey());
stringRedisTemplate.opsForZSet().removeRangeByScore(key, SCORE_MIN, nowEpochSeconds - 1);
for (PlayClerkPkEntity pk : entry.getValue()) {
if (pk.getPkBeginTime() == null || pk.getId() == null) {
continue;
}
long score = pk.getPkBeginTime().toInstant().getEpochSecond();
stringRedisTemplate.opsForZSet().add(key, pk.getId(), score);
}
stringRedisTemplate.expire(key, PkRedisKeyConstants.UPCOMING_REMINDER_TTL_SECONDS, TimeUnit.SECONDS);
}
}
}

View File

@@ -0,0 +1,19 @@
package com.starry.admin.modules.pk.scheduler.constants;
public final class PkSchedulerConstants {
public static final long START_SCAN_INTERVAL_MILLIS = 1000L;
public static final long FINISH_SCAN_INTERVAL_MILLIS = 1000L;
public static final long FALLBACK_SCAN_INTERVAL_MILLIS = 300000L;
public static final long START_LOCK_TTL_MILLIS = 5000L;
public static final long FINISH_LOCK_TTL_MILLIS = 5000L;
public static final int FINISH_RETRY_MAX_ATTEMPTS = 3;
public static final int[] FINISH_RETRY_BACKOFF_SECONDS = {5, 10, 20};
public static final int START_RETRY_DELAY_SECONDS = 5;
private PkSchedulerConstants() {
}
}

View File

@@ -0,0 +1,144 @@
package com.starry.admin.modules.pk.scheduler.task;
import cn.hutool.core.util.StrUtil;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants;
import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import com.starry.admin.modules.system.service.ISysTenantService;
import com.starry.admin.utils.TenantScope;
import com.starry.common.utils.IdUtils;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class PkFinishSchedulerJob {
private static final double SCORE_MIN = Double.NEGATIVE_INFINITY;
private final StringRedisTemplate stringRedisTemplate;
private final ClerkPkLifecycleService clerkPkLifecycleService;
private final ISysTenantService sysTenantService;
public PkFinishSchedulerJob(StringRedisTemplate stringRedisTemplate,
ClerkPkLifecycleService clerkPkLifecycleService,
ISysTenantService sysTenantService) {
this.stringRedisTemplate = stringRedisTemplate;
this.clerkPkLifecycleService = clerkPkLifecycleService;
this.sysTenantService = sysTenantService;
}
@Scheduled(fixedDelay = PkSchedulerConstants.FINISH_SCAN_INTERVAL_MILLIS)
public void scanFinishSchedule() {
List<SysTenantEntity> tenantEntities = sysTenantService.listAll();
if (tenantEntities.isEmpty()) {
return;
}
long nowEpochSeconds = Instant.now().getEpochSecond();
for (SysTenantEntity tenantEntity : tenantEntities) {
String tenantId = tenantEntity.getTenantId();
if (StrUtil.isBlank(tenantId)) {
log.warn("PK结算调度跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name());
continue;
}
try {
handleTenant(tenantId, nowEpochSeconds);
} catch (Exception ex) {
log.error("PK结算调度失败, tenantId={}", tenantId, ex);
continue;
}
}
}
private void handleTenant(String tenantId, long nowEpochSeconds) {
String lockKey = PkRedisKeyConstants.finishLockKey(tenantId);
String lockValue = IdUtils.getUuid();
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(
lockKey,
lockValue,
PkSchedulerConstants.FINISH_LOCK_TTL_MILLIS,
TimeUnit.MILLISECONDS);
if (!Boolean.TRUE.equals(locked)) {
return;
}
try {
try (TenantScope scope = TenantScope.use(tenantId)) {
String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId);
Set<String> duePkIds = stringRedisTemplate.opsForZSet()
.rangeByScore(scheduleKey, SCORE_MIN, nowEpochSeconds);
if (duePkIds == null || duePkIds.isEmpty()) {
return;
}
for (String pkId : duePkIds) {
processFinish(scheduleKey, tenantId, pkId, nowEpochSeconds);
}
}
} finally {
releaseLock(lockKey, lockValue);
}
}
private void processFinish(String scheduleKey, String tenantId, String pkId, long nowEpochSeconds) {
if (StrUtil.isBlank(pkId)) {
return;
}
try {
clerkPkLifecycleService.finishPk(pkId);
clearRetry(tenantId, pkId);
removeFailed(tenantId, pkId);
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
} catch (Exception ex) {
handleRetry(scheduleKey, tenantId, pkId, nowEpochSeconds, ex);
}
}
private void handleRetry(String scheduleKey, String tenantId, String pkId, long nowEpochSeconds, Exception ex) {
String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId);
Long attempt = stringRedisTemplate.opsForHash().increment(retryKey, pkId, 1L);
int retryCount = attempt == null ? 1 : attempt.intValue();
if (retryCount >= PkSchedulerConstants.FINISH_RETRY_MAX_ATTEMPTS) {
String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId);
stringRedisTemplate.opsForZSet().add(failedKey, pkId, nowEpochSeconds);
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
clearRetry(tenantId, pkId);
log.error("PK 自动结算失败超过重试上限, pkId={}, retryCount={}", pkId, retryCount, ex);
return;
}
long backoffSeconds = resolveBackoffSeconds(retryCount);
long nextScore = nowEpochSeconds + backoffSeconds;
stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, nextScore);
log.warn("PK 自动结算失败, pkId={}, retryCount={}, nextScore={}", pkId, retryCount, nextScore, ex);
}
private long resolveBackoffSeconds(int retryCount) {
int[] backoffs = PkSchedulerConstants.FINISH_RETRY_BACKOFF_SECONDS;
int index = Math.min(retryCount, backoffs.length) - 1;
return backoffs[index];
}
private void clearRetry(String tenantId, String pkId) {
String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId);
stringRedisTemplate.opsForHash().delete(retryKey, pkId);
}
private void removeFailed(String tenantId, String pkId) {
String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId);
stringRedisTemplate.opsForZSet().remove(failedKey, pkId);
}
private void releaseLock(String lockKey, String lockValue) {
String currentValue = stringRedisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
stringRedisTemplate.delete(lockKey);
}
}
}

View File

@@ -0,0 +1,133 @@
package com.starry.admin.modules.pk.scheduler.task;
import cn.hutool.core.util.StrUtil;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants;
import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import com.starry.admin.modules.system.service.ISysTenantService;
import com.starry.admin.utils.TenantScope;
import com.starry.common.utils.IdUtils;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class PkStartSchedulerJob {
private static final double SCORE_MIN = Double.NEGATIVE_INFINITY;
private final StringRedisTemplate stringRedisTemplate;
private final ClerkPkLifecycleService clerkPkLifecycleService;
private final IPlayClerkPkService clerkPkService;
private final ISysTenantService sysTenantService;
public PkStartSchedulerJob(StringRedisTemplate stringRedisTemplate,
ClerkPkLifecycleService clerkPkLifecycleService,
IPlayClerkPkService clerkPkService,
ISysTenantService sysTenantService) {
this.stringRedisTemplate = stringRedisTemplate;
this.clerkPkLifecycleService = clerkPkLifecycleService;
this.clerkPkService = clerkPkService;
this.sysTenantService = sysTenantService;
}
@Scheduled(fixedDelay = PkSchedulerConstants.START_SCAN_INTERVAL_MILLIS)
public void scanStartSchedule() {
List<SysTenantEntity> tenantEntities = sysTenantService.listAll();
if (tenantEntities.isEmpty()) {
return;
}
long nowEpochSeconds = Instant.now().getEpochSecond();
for (SysTenantEntity tenantEntity : tenantEntities) {
String tenantId = tenantEntity.getTenantId();
if (StrUtil.isBlank(tenantId)) {
log.warn("PK开始调度跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name());
continue;
}
try {
handleTenant(tenantId, nowEpochSeconds);
} catch (Exception ex) {
log.error("PK开始调度失败, tenantId={}", tenantId, ex);
continue;
}
}
}
private void handleTenant(String tenantId, long nowEpochSeconds) {
String lockKey = PkRedisKeyConstants.startLockKey(tenantId);
String lockValue = IdUtils.getUuid();
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(
lockKey,
lockValue,
PkSchedulerConstants.START_LOCK_TTL_MILLIS,
TimeUnit.MILLISECONDS);
if (!Boolean.TRUE.equals(locked)) {
return;
}
try {
try (TenantScope scope = TenantScope.use(tenantId)) {
String scheduleKey = PkRedisKeyConstants.startScheduleKey(tenantId);
Set<String> duePkIds = stringRedisTemplate.opsForZSet()
.rangeByScore(scheduleKey, SCORE_MIN, nowEpochSeconds);
if (duePkIds == null || duePkIds.isEmpty()) {
return;
}
for (String pkId : duePkIds) {
processStart(scheduleKey, pkId, nowEpochSeconds);
}
}
} finally {
releaseLock(lockKey, lockValue);
}
}
private void processStart(String scheduleKey, String pkId, long nowEpochSeconds) {
if (StrUtil.isBlank(pkId)) {
return;
}
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
if (pk == null) {
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
return;
}
if (!ClerkPkEnum.TO_BE_STARTED.name().equals(pk.getStatus())) {
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
return;
}
if (pk.getPkBeginTime() == null) {
stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId);
return;
}
long beginEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond();
if (beginEpochSeconds > nowEpochSeconds) {
stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, beginEpochSeconds);
return;
}
try {
clerkPkLifecycleService.startPk(pkId);
} catch (Exception ex) {
long retryAt = nowEpochSeconds + PkSchedulerConstants.START_RETRY_DELAY_SECONDS;
stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, retryAt);
log.warn("PK 自动开始失败, pkId={}, retryAt={}", pkId, retryAt, ex);
}
}
private void releaseLock(String lockKey, String lockValue) {
String currentValue = stringRedisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
stringRedisTemplate.delete(lockKey);
}
}
}

View File

@@ -0,0 +1,36 @@
package com.starry.admin.modules.pk.service;
import com.starry.admin.modules.pk.dto.PlayClerkPkForceStartRequest;
/**
* 店员 PK 生命周期管理服务。
*/
public interface ClerkPkLifecycleService {
/**
* 启动指定 PK从待开始进入进行中
*
* @param pkId PK ID
*/
void startPk(String pkId);
/**
* 完成并结算指定 PK。
*
* @param pkId PK ID
*/
void finishPk(String pkId);
/**
* 扫描当前需要状态流转的 PK。
*/
void scanAndUpdate();
/**
* 强制开始PK用于测试无需排期
*
* @param request 强制开始请求
* @return PK ID
*/
String forceStart(PlayClerkPkForceStartRequest request);
}

View File

@@ -0,0 +1,16 @@
package com.starry.admin.modules.pk.service;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto;
import com.starry.admin.modules.pk.dto.WxPkContributorDto;
import com.starry.admin.modules.pk.dto.WxPkHistoryDto;
import java.util.List;
public interface IPkDetailService {
List<WxPkContributorDto> getContributors(PlayClerkPkEntity pk);
List<WxPkHistoryDto> getHistory(PlayClerkPkEntity pk);
WxPkClerkHistoryPageDto getClerkHistory(String clerkId, int pageNum, int pageSize);
}

View File

@@ -0,0 +1,17 @@
package com.starry.admin.modules.pk.service;
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
/**
* PK 实时比分查询服务。
*/
public interface IPkScoreboardService {
/**
* 查询指定 PK 的实时比分。
*
* @param pkId PK ID
* @return 比分信息(不会为 null
*/
PkScoreBoardDto getScoreboard(String pkId);
}

View File

@@ -0,0 +1,273 @@
package com.starry.admin.modules.pk.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.pk.dto.PlayClerkPkForceStartRequest;
import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants;
import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
import com.starry.admin.modules.pk.setting.constants.PkSettingValidationConstants;
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
import com.starry.admin.modules.system.service.ISysTenantService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.admin.utils.TenantScope;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService {
@Resource
private IPlayClerkPkService clerkPkService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private ISysTenantService sysTenantService;
@Override
@Transactional(rollbackFor = Exception.class)
public void startPk(String pkId) {
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
if (pk == null) {
throw new CustomException("PK不存在");
}
if (!ClerkPkEnum.TO_BE_STARTED.name().equals(pk.getStatus())) {
removeStartSchedule(pk);
return;
}
LocalDateTime now = LocalDateTime.now();
if (pk.getPkBeginTime() != null
&& LocalDateTime.ofInstant(pk.getPkBeginTime().toInstant(), ZoneId.systemDefault()).isAfter(now)) {
throw new CustomException("PK开始时间尚未到达");
}
pk.setStatus(ClerkPkEnum.IN_PROGRESS.name());
clerkPkService.updateById(pk);
removeStartSchedule(pk);
scheduleFinish(pk);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void finishPk(String pkId) {
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
if (pk == null) {
throw new CustomException("PK不存在");
}
if (ClerkPkEnum.FINISHED.name().equals(pk.getStatus())) {
removeFinishSchedule(pk);
return;
}
String scoreKey = PkRedisKeyConstants.scoreKey(pkId);
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(scoreKey);
BigDecimal clerkAScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_SCORE));
BigDecimal clerkBScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_SCORE));
int clerkAOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT));
int clerkBOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT));
pk.setClerkAScore(clerkAScore);
pk.setClerkBScore(clerkBScore);
pk.setClerkAOrderCount(clerkAOrderCount);
pk.setClerkBOrderCount(clerkBOrderCount);
pk.setStatus(ClerkPkEnum.FINISHED.name());
pk.setSettled(1);
if (clerkAScore.compareTo(clerkBScore) > 0) {
pk.setWinnerClerkId(pk.getClerkA());
} else if (clerkBScore.compareTo(clerkAScore) > 0) {
pk.setWinnerClerkId(pk.getClerkB());
} else {
pk.setWinnerClerkId(null);
}
clerkPkService.updateById(pk);
removeFinishSchedule(pk);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void scanAndUpdate() {
LocalDateTime now = LocalDateTime.now();
Date nowDate = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
clerkPkService.list(Wrappers.<PlayClerkPkEntity>lambdaQuery()
.eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.TO_BE_STARTED.name())
.le(PlayClerkPkEntity::getPkBeginTime, nowDate))
.forEach(pk -> {
try {
startPk(pk.getId());
} catch (Exception e) {
log.warn("自动开始PK失败, pkId={}", pk.getId(), e);
}
});
clerkPkService.list(Wrappers.<PlayClerkPkEntity>lambdaQuery()
.in(PlayClerkPkEntity::getStatus,
ClerkPkEnum.IN_PROGRESS.name(), ClerkPkEnum.TO_BE_STARTED.name())
.le(PlayClerkPkEntity::getPkEndTime, nowDate)
.eq(PlayClerkPkEntity::getSettled, 0))
.forEach(pk -> {
try {
finishPk(pk.getId());
} catch (Exception e) {
log.warn("自动结束PK失败, pkId={}", pk.getId(), e);
}
});
}
@Override
@Transactional(rollbackFor = Exception.class)
public String forceStart(PlayClerkPkForceStartRequest request) {
validateForceStartRequest(request);
String tenantId = SecurityUtils.getTenantId();
if (StrUtil.isBlank(tenantId)) {
throw new CustomException(PkLifecycleErrorCode.TENANT_MISSING.getMessage());
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime endTime = now.plusMinutes(request.getDurationMinutes());
Date beginDate = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
Date endDate = Date.from(endTime.atZone(ZoneId.systemDefault()).toInstant());
if (hasClerkConflict(request.getClerkAId(), request.getClerkBId(), beginDate, endDate)) {
throw new CustomException(PkLifecycleErrorCode.CLERK_CONFLICT.getMessage());
}
PlayClerkPkEntity pk = new PlayClerkPkEntity();
pk.setId(IdUtils.getUuid());
pk.setTenantId(tenantId);
pk.setClerkA(request.getClerkAId());
pk.setClerkB(request.getClerkBId());
pk.setPkBeginTime(beginDate);
pk.setPkEndTime(endDate);
pk.setStatus(ClerkPkEnum.IN_PROGRESS.name());
pk.setSettled(0);
pk.setCreatedBy(SecurityUtils.getUserId());
clerkPkService.save(pk);
scheduleFinish(pk);
return pk.getId();
}
@Scheduled(fixedDelay = PkSchedulerConstants.FALLBACK_SCAN_INTERVAL_MILLIS)
public void scheduledScan() {
scanAndUpdateForAllTenants();
}
private void scanAndUpdateForAllTenants() {
List<SysTenantEntity> tenantEntities = sysTenantService.listAll();
if (tenantEntities.isEmpty()) {
return;
}
for (SysTenantEntity tenantEntity : tenantEntities) {
String tenantId = tenantEntity.getTenantId();
if (StrUtil.isBlank(tenantId)) {
log.warn("PK扫描跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name());
continue;
}
try (TenantScope scope = TenantScope.use(tenantId)) {
scanAndUpdate();
}
}
}
private void scheduleFinish(PlayClerkPkEntity pk) {
if (pk == null || pk.getPkEndTime() == null) {
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
}
String tenantId = pk.getTenantId();
if (StrUtil.isBlank(tenantId)) {
throw new CustomException(PkLifecycleErrorCode.TENANT_MISSING.getMessage());
}
long endEpochSeconds = pk.getPkEndTime().toInstant().getEpochSecond();
String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId);
stringRedisTemplate.opsForZSet().add(scheduleKey, pk.getId(), endEpochSeconds);
}
private void removeStartSchedule(PlayClerkPkEntity pk) {
if (pk == null || StrUtil.isBlank(pk.getTenantId())) {
return;
}
String scheduleKey = PkRedisKeyConstants.startScheduleKey(pk.getTenantId());
stringRedisTemplate.opsForZSet().remove(scheduleKey, pk.getId());
}
private void removeFinishSchedule(PlayClerkPkEntity pk) {
if (pk == null || StrUtil.isBlank(pk.getTenantId())) {
return;
}
String tenantId = pk.getTenantId();
String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId);
stringRedisTemplate.opsForZSet().remove(scheduleKey, pk.getId());
String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId);
stringRedisTemplate.opsForHash().delete(retryKey, pk.getId());
String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId);
stringRedisTemplate.opsForZSet().remove(failedKey, pk.getId());
}
private static BigDecimal parseDecimal(Object value) {
if (value == null) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(value.toString());
} catch (NumberFormatException ex) {
return BigDecimal.ZERO;
}
}
private static int parseInt(Object value) {
if (value == null) {
return 0;
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException ex) {
return 0;
}
}
private void validateForceStartRequest(PlayClerkPkForceStartRequest request) {
if (request == null
|| StrUtil.isBlank(request.getClerkAId())
|| StrUtil.isBlank(request.getClerkBId())
|| request.getDurationMinutes() == null) {
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
}
if (request.getClerkAId().equals(request.getClerkBId())) {
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
}
if (request.getDurationMinutes() < PkSettingValidationConstants.MIN_DURATION_MINUTES) {
throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage());
}
}
private boolean hasClerkConflict(String clerkAId, String clerkBId, Date beginTime, Date endTime) {
return clerkPkService.count(Wrappers.<PlayClerkPkEntity>lambdaQuery()
.in(PlayClerkPkEntity::getStatus,
ClerkPkEnum.TO_BE_STARTED.name(),
ClerkPkEnum.IN_PROGRESS.name())
.and(time -> time.le(PlayClerkPkEntity::getPkBeginTime, endTime)
.ge(PlayClerkPkEntity::getPkEndTime, beginTime))
.and(clerk -> clerk.eq(PlayClerkPkEntity::getClerkA, clerkAId)
.or()
.eq(PlayClerkPkEntity::getClerkB, clerkAId)
.or()
.eq(PlayClerkPkEntity::getClerkA, clerkBId)
.or()
.eq(PlayClerkPkEntity::getClerkB, clerkBId))) > 0;
}
}

View File

@@ -0,0 +1,265 @@
package com.starry.admin.modules.pk.service.impl;
import cn.hutool.core.util.StrUtil;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper;
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.pk.constants.PkWxQueryConstants;
import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto;
import com.starry.admin.modules.pk.dto.WxPkClerkHistorySummaryDto;
import com.starry.admin.modules.pk.dto.WxPkContributorDto;
import com.starry.admin.modules.pk.dto.WxPkHistoryDto;
import com.starry.admin.modules.pk.service.IPkDetailService;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class PkDetailServiceImpl implements IPkDetailService {
private static final ZoneId DEFAULT_ZONE = ZoneId.systemDefault();
@Resource
private PlayOrderInfoMapper orderInfoMapper;
@Resource
private PlayClerkPkMapper clerkPkMapper;
@Resource
private IPlayClerkUserInfoService clerkUserInfoService;
@Override
public List<WxPkContributorDto> getContributors(PlayClerkPkEntity pk) {
validatePk(pk);
LocalDateTime startTime = toLocalDateTime(pk.getPkBeginTime());
LocalDateTime endTime = toLocalDateTime(pk.getPkEndTime());
LocalDateTime now = LocalDateTime.now(DEFAULT_ZONE);
if (endTime.isAfter(now)) {
endTime = now;
}
if (endTime.isBefore(startTime)) {
return Collections.emptyList();
}
List<WxPkContributorDto> contributors = orderInfoMapper.selectPkContributors(
pk.getTenantId(),
pk.getClerkA(),
pk.getClerkB(),
startTime,
endTime,
OrderConstant.OrderStatus.COMPLETED.getCode(),
PkWxQueryConstants.MIN_CONTRIBUTION_AMOUNT,
PkWxQueryConstants.CONTRIBUTOR_LIMIT);
if (contributors == null || contributors.isEmpty()) {
return Collections.emptyList();
}
List<WxPkContributorDto> normalized = new ArrayList<>();
for (WxPkContributorDto contributor : contributors) {
normalized.add(normalizeContributor(contributor));
}
return normalized;
}
@Override
public List<WxPkHistoryDto> getHistory(PlayClerkPkEntity pk) {
validatePk(pk);
List<PlayClerkPkEntity> history = clerkPkMapper.selectRecentFinishedBetweenClerks(
pk.getTenantId(),
pk.getClerkA(),
pk.getClerkB(),
ClerkPkEnum.FINISHED.name(),
PkWxQueryConstants.HISTORY_LIMIT);
if (history == null || history.isEmpty()) {
return Collections.emptyList();
}
Map<String, String> clerkNames = loadClerkNames(pk.getClerkA(), pk.getClerkB());
List<WxPkHistoryDto> items = new ArrayList<>();
for (PlayClerkPkEntity item : history) {
items.add(toHistoryDto(item, clerkNames));
}
return items;
}
@Override
public WxPkClerkHistoryPageDto getClerkHistory(String clerkId, int pageNum, int pageSize) {
if (StrUtil.isBlank(clerkId)) {
throw new CustomException("店员ID不能为空");
}
int safePageNum = normalizePageParam(pageNum, PkWxQueryConstants.CLERK_HISTORY_PAGE_NUM);
int safePageSize = normalizePageParam(pageSize, PkWxQueryConstants.CLERK_HISTORY_PAGE_SIZE);
com.baomidou.mybatisplus.extension.plugins.pagination.Page<PlayClerkPkEntity> page =
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(safePageNum, safePageSize);
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlayClerkPkEntity> wrapper =
com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery(PlayClerkPkEntity.class)
.eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.FINISHED.name())
.and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId)
.or()
.eq(PlayClerkPkEntity::getClerkB, clerkId))
.orderByDesc(PlayClerkPkEntity::getPkBeginTime);
com.baomidou.mybatisplus.extension.plugins.pagination.Page<PlayClerkPkEntity> result =
clerkPkMapper.selectPage(page, wrapper);
List<WxPkHistoryDto> items = new ArrayList<>();
if (result.getRecords() != null) {
for (PlayClerkPkEntity item : result.getRecords()) {
items.add(toClerkHistoryDto(item));
}
}
WxPkClerkHistoryPageDto pageDto = new WxPkClerkHistoryPageDto();
pageDto.setItems(items);
pageDto.setTotalCount(result.getTotal());
pageDto.setPageNum(safePageNum);
pageDto.setPageSize(safePageSize);
pageDto.setSummary(buildSummary(clerkId, result.getTotal()));
return pageDto;
}
private void validatePk(PlayClerkPkEntity pk) {
if (pk == null) {
throw new CustomException("PK不存在");
}
if (StrUtil.isBlank(pk.getTenantId())) {
throw new CustomException("租户信息缺失");
}
if (StrUtil.isBlank(pk.getClerkA()) || StrUtil.isBlank(pk.getClerkB())) {
throw new CustomException("PK店员信息缺失");
}
if (pk.getPkBeginTime() == null || pk.getPkEndTime() == null) {
throw new CustomException("PK时间信息缺失");
}
}
private LocalDateTime toLocalDateTime(java.util.Date date) {
if (date == null) {
throw new CustomException("时间信息缺失");
}
return LocalDateTime.ofInstant(date.toInstant(), DEFAULT_ZONE);
}
private WxPkContributorDto normalizeContributor(WxPkContributorDto source) {
WxPkContributorDto dto = new WxPkContributorDto();
if (source == null) {
return dto;
}
if (StrUtil.isNotBlank(source.getUserId())) {
dto.setUserId(source.getUserId());
}
if (StrUtil.isNotBlank(source.getNickname())) {
dto.setNickname(source.getNickname());
}
if (source.getAmount() != null) {
dto.setAmount(source.getAmount());
}
return dto;
}
private Map<String, String> loadClerkNames(String clerkAId, String clerkBId) {
Map<String, String> names = new HashMap<>();
PlayClerkUserInfoEntity clerkA = clerkUserInfoService.getById(clerkAId);
PlayClerkUserInfoEntity clerkB = clerkUserInfoService.getById(clerkBId);
names.put(clerkAId, clerkA == null ? "" : safeText(clerkA.getNickname()));
names.put(clerkBId, clerkB == null ? "" : safeText(clerkB.getNickname()));
return names;
}
private WxPkHistoryDto toHistoryDto(PlayClerkPkEntity item, Map<String, String> clerkNames) {
WxPkHistoryDto dto = new WxPkHistoryDto();
if (item == null) {
return dto;
}
dto.setClerkAName(safeText(clerkNames.get(item.getClerkA())));
dto.setClerkBName(safeText(clerkNames.get(item.getClerkB())));
dto.setClerkAScore(safeAmount(item.getClerkAScore()));
dto.setClerkBScore(safeAmount(item.getClerkBScore()));
dto.setId(safeText(item.getId()));
dto.setClerkAId(safeText(item.getClerkA()));
dto.setClerkBId(safeText(item.getClerkB()));
dto.setWinnerClerkId(safeText(item.getWinnerClerkId()));
if (item.getPkBeginTime() != null) {
dto.setPkBeginTime(item.getPkBeginTime());
}
return dto;
}
private String safeText(String value) {
return value == null ? "" : value;
}
private BigDecimal safeAmount(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private int normalizePageParam(int value, int defaultValue) {
if (value < PkWxQueryConstants.CLERK_HISTORY_MIN_PAGE) {
return defaultValue;
}
return value;
}
private WxPkHistoryDto toClerkHistoryDto(PlayClerkPkEntity item) {
WxPkHistoryDto dto = toHistoryDto(item, loadClerkNames(item.getClerkA(), item.getClerkB()));
WxPkContributorDto topContributor = loadTopContributor(item);
if (topContributor != null) {
dto.setTopContributorName(safeText(topContributor.getNickname()));
dto.setTopContributorAmount(safeAmount(topContributor.getAmount()));
}
return dto;
}
private WxPkContributorDto loadTopContributor(PlayClerkPkEntity pk) {
if (pk == null || pk.getPkBeginTime() == null || pk.getPkEndTime() == null) {
return null;
}
LocalDateTime startTime = toLocalDateTime(pk.getPkBeginTime());
LocalDateTime endTime = toLocalDateTime(pk.getPkEndTime());
List<WxPkContributorDto> contributors = orderInfoMapper.selectPkContributors(
pk.getTenantId(),
pk.getClerkA(),
pk.getClerkB(),
startTime,
endTime,
OrderConstant.OrderStatus.COMPLETED.getCode(),
PkWxQueryConstants.MIN_CONTRIBUTION_AMOUNT,
PkWxQueryConstants.TOP_CONTRIBUTOR_LIMIT);
if (contributors == null || contributors.isEmpty()) {
return null;
}
return normalizeContributor(contributors.get(0));
}
private WxPkClerkHistorySummaryDto buildSummary(String clerkId, long totalCount) {
WxPkClerkHistorySummaryDto summary = new WxPkClerkHistorySummaryDto();
summary.setTotalCount(totalCount);
if (totalCount <= 0) {
return summary;
}
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PlayClerkPkEntity> winWrapper =
com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery(PlayClerkPkEntity.class)
.eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.FINISHED.name())
.and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId)
.or()
.eq(PlayClerkPkEntity::getClerkB, clerkId))
.eq(PlayClerkPkEntity::getWinnerClerkId, clerkId);
long winCount = clerkPkMapper.selectCount(winWrapper);
summary.setWinCount(winCount);
BigDecimal rate = BigDecimal.valueOf(winCount)
.multiply(PkWxQueryConstants.WIN_RATE_MULTIPLIER)
.divide(BigDecimal.valueOf(totalCount), PkWxQueryConstants.WIN_RATE_SCALE, RoundingMode.HALF_UP);
summary.setWinRate(rate.stripTrailingZeros().toPlainString() + PkWxQueryConstants.PERCENT_SUFFIX);
return summary;
}
}

View File

@@ -0,0 +1,87 @@
package com.starry.admin.modules.pk.service.impl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import com.starry.admin.modules.pk.service.IPkScoreboardService;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Map;
import javax.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class PkScoreboardServiceImpl implements IPkScoreboardService {
@Resource
private IPlayClerkPkService clerkPkService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public PkScoreBoardDto getScoreboard(String pkId) {
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
if (pk == null) {
throw new CustomException("PK不存在");
}
PkScoreBoardDto dto = new PkScoreBoardDto();
dto.setPkId(pk.getId());
dto.setClerkAId(pk.getClerkA());
dto.setClerkBId(pk.getClerkB());
String scoreKey = PkRedisKeyConstants.scoreKey(pkId);
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(scoreKey);
BigDecimal clerkAScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_SCORE));
BigDecimal clerkBScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_SCORE));
int clerkAOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT));
int clerkBOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT));
dto.setClerkAScore(clerkAScore);
dto.setClerkBScore(clerkBScore);
dto.setClerkAOrderCount(clerkAOrderCount);
dto.setClerkBOrderCount(clerkBOrderCount);
dto.setRemainingSeconds(calculateRemainingSeconds(pk));
return dto;
}
private static BigDecimal parseDecimal(Object value) {
if (value == null) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(value.toString());
} catch (NumberFormatException ex) {
return BigDecimal.ZERO;
}
}
private static int parseInt(Object value) {
if (value == null) {
return 0;
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException ex) {
return 0;
}
}
private static long calculateRemainingSeconds(PlayClerkPkEntity pk) {
if (pk.getPkEndTime() == null) {
return 0L;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime endTime = LocalDateTime.ofInstant(pk.getPkEndTime().toInstant(), ZoneId.systemDefault());
if (endTime.isBefore(now)) {
return 0L;
}
return Duration.between(now, endTime).getSeconds();
}
}

View File

@@ -0,0 +1,16 @@
package com.starry.admin.modules.pk.setting.constants;
public final class PkSettingApiConstants {
public static final String BASE_PATH = "/play/pk/settings";
public static final String LIST_PATH = "/list";
public static final String DETAIL_PATH = "/{id}";
public static final String CREATE_PATH = "/create";
public static final String BULK_CREATE_PATH = "/bulk-create";
public static final String UPDATE_PATH = "/update/{id}";
public static final String ENABLE_PATH = "/{id}/enable";
public static final String DISABLE_PATH = "/{id}/disable";
private PkSettingApiConstants() {
}
}

View File

@@ -0,0 +1,16 @@
package com.starry.admin.modules.pk.setting.constants;
public final class PkSettingValidationConstants {
public static final int MIN_DURATION_MINUTES = 1;
public static final int MAX_YEARS_AHEAD = 3;
public static final int MIN_DAY_OF_MONTH = 1;
public static final int MAX_DAY_OF_MONTH = 31;
public static final int MIN_MONTH_OF_YEAR = 1;
public static final int MAX_MONTH_OF_YEAR = 12;
public static final int DEFAULT_PAGE_NUMBER = 1;
public static final int DEFAULT_PAGE_SIZE = 10;
private PkSettingValidationConstants() {
}
}

View File

@@ -0,0 +1,118 @@
package com.starry.admin.modules.pk.setting.controller;
import com.starry.admin.modules.pk.setting.constants.PkSettingApiConstants;
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest;
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest;
import com.starry.admin.modules.pk.setting.service.IPlayClerkPkSettingService;
import com.starry.common.annotation.Log;
import com.starry.common.enums.BusinessType;
import com.starry.common.result.R;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Api(tags = "店员PK排期设置", description = "店员PK排期设置相关接口")
@RestController
@RequestMapping(PkSettingApiConstants.BASE_PATH)
public class PlayClerkPkSettingController {
@Resource
private IPlayClerkPkSettingService pkSettingService;
/**
* 查询PK排期设置列表
*/
@ApiOperation(value = "查询PK排期设置列表", notes = "分页查询PK排期设置")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
@GetMapping(PkSettingApiConstants.LIST_PATH)
public R list() {
return R.ok(pkSettingService.listSettings());
}
/**
* 获取PK排期设置详情
*/
@ApiOperation(value = "获取PK排期设置详情", notes = "根据ID获取PK排期设置详情")
@ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
@GetMapping(PkSettingApiConstants.DETAIL_PATH)
public R getInfo(@PathVariable("id") String id) {
return R.ok(pkSettingService.getSetting(id));
}
/**
* 新增PK排期设置
*/
@ApiOperation(value = "新增PK排期设置", notes = "创建新的PK排期设置")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "添加失败")})
@Log(title = "店员PK排期设置", businessType = BusinessType.INSERT)
@PostMapping(PkSettingApiConstants.CREATE_PATH)
public R create(
@ApiParam(value = "PK排期设置", required = true) @RequestBody PlayClerkPkSettingUpsertRequest request) {
String id = pkSettingService.createSetting(request);
return R.ok(id);
}
/**
* 批量新增PK排期设置
*/
@ApiOperation(value = "批量新增PK排期设置", notes = "批量创建多个PK排期设置")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "添加失败")})
@Log(title = "店员PK排期设置", businessType = BusinessType.INSERT)
@PostMapping(PkSettingApiConstants.BULK_CREATE_PATH)
public R bulkCreate(
@ApiParam(value = "PK排期设置列表", required = true)
@RequestBody PlayClerkPkSettingBulkCreateRequest request) {
return R.ok(pkSettingService.createSettings(request));
}
/**
* 修改PK排期设置
*/
@ApiOperation(value = "修改PK排期设置", notes = "根据ID修改PK排期设置")
@ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "修改失败")})
@Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE)
@PostMapping(PkSettingApiConstants.UPDATE_PATH)
public R update(@PathVariable("id") String id,
@ApiParam(value = "PK排期设置", required = true) @RequestBody PlayClerkPkSettingUpsertRequest request) {
pkSettingService.updateSetting(id, request);
return R.ok();
}
/**
* 启用PK排期设置
*/
@ApiOperation(value = "启用PK排期设置", notes = "启用指定PK排期设置")
@ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
@Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE)
@PostMapping(PkSettingApiConstants.ENABLE_PATH)
public R enable(@PathVariable("id") String id) {
pkSettingService.enableSetting(id);
return R.ok();
}
/**
* 停用PK排期设置
*/
@ApiOperation(value = "停用PK排期设置", notes = "停用指定PK排期设置")
@ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功")})
@Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE)
@PostMapping(PkSettingApiConstants.DISABLE_PATH)
public R disable(@PathVariable("id") String id) {
pkSettingService.disableSetting(id);
return R.ok();
}
}

View File

@@ -0,0 +1,14 @@
package com.starry.admin.modules.pk.setting.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.util.List;
import lombok.Data;
@ApiModel(value = "PlayClerkPkSettingBulkCreateRequest", description = "店员PK排期设置批量创建请求")
@Data
public class PlayClerkPkSettingBulkCreateRequest {
@ApiModelProperty(value = "批量排期设置列表", required = true)
private List<PlayClerkPkSettingUpsertRequest> settings;
}

View File

@@ -0,0 +1,54 @@
package com.starry.admin.modules.pk.setting.dto;
import com.starry.admin.modules.pk.setting.enums.PkRecurrenceType;
import com.starry.admin.modules.pk.setting.enums.PkScheduleDayOfWeek;
import com.starry.admin.modules.pk.setting.enums.PkSettingStatus;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.time.LocalDate;
import java.time.LocalTime;
import lombok.Data;
@ApiModel(value = "PlayClerkPkSettingUpsertRequest", description = "店员PK排期设置创建/更新请求")
@Data
public class PlayClerkPkSettingUpsertRequest {
@ApiModelProperty(value = "设置名称", required = true)
private String name;
@ApiModelProperty(value = "循环类型", required = true)
private PkRecurrenceType recurrenceType;
@ApiModelProperty(value = "星期几")
private PkScheduleDayOfWeek dayOfWeek;
@ApiModelProperty(value = "每月第N天")
private Integer dayOfMonth;
@ApiModelProperty(value = "每年月份")
private Integer monthOfYear;
@ApiModelProperty(value = "每日开始时间", required = true)
private LocalTime startTimeOfDay;
@ApiModelProperty(value = "持续分钟数", required = true)
private Integer durationMinutes;
@ApiModelProperty(value = "生效开始日期", required = true)
private LocalDate effectiveStartDate;
@ApiModelProperty(value = "生效结束日期")
private LocalDate effectiveEndDate;
@ApiModelProperty(value = "时区", required = true)
private String timezone;
@ApiModelProperty(value = "店员A ID", required = true)
private String clerkAId;
@ApiModelProperty(value = "店员B ID", required = true)
private String clerkBId;
@ApiModelProperty(value = "状态", required = true)
private PkSettingStatus status;
}

View File

@@ -0,0 +1,62 @@
package com.starry.admin.modules.pk.setting.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.common.domain.BaseEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.time.LocalDate;
import java.time.LocalTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ApiModel(value = "PlayClerkPkSettingEntity", description = "店员PK排期设置")
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_clerk_pk_settings")
public class PlayClerkPkSettingEntity extends BaseEntity<PlayClerkPkSettingEntity> {
@ApiModelProperty(value = "UUID", required = true)
private String id;
@ApiModelProperty(value = "租户ID", required = true)
private String tenantId;
@ApiModelProperty(value = "设置名称", required = true)
private String name;
@ApiModelProperty(value = "循环类型", required = true)
private String recurrenceType;
@ApiModelProperty(value = "星期几")
private String dayOfWeek;
@ApiModelProperty(value = "每月第N天")
private Integer dayOfMonth;
@ApiModelProperty(value = "每年月份")
private Integer monthOfYear;
@ApiModelProperty(value = "每日开始时间", required = true)
private LocalTime startTimeOfDay;
@ApiModelProperty(value = "持续分钟数", required = true)
private Integer durationMinutes;
@ApiModelProperty(value = "生效开始日期", required = true)
private LocalDate effectiveStartDate;
@ApiModelProperty(value = "生效结束日期")
private LocalDate effectiveEndDate;
@ApiModelProperty(value = "时区", required = true)
private String timezone;
@ApiModelProperty(value = "店员A ID", required = true)
private String clerkAId;
@ApiModelProperty(value = "店员B ID", required = true)
private String clerkBId;
@ApiModelProperty(value = "状态", required = true)
private String status;
}

View File

@@ -0,0 +1,18 @@
package com.starry.admin.modules.pk.setting.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
public enum PkRecurrenceType {
ONCE("ONCE", "单次"),
DAILY("DAILY", "每日"),
WEEKLY("WEEKLY", "每周"),
MONTHLY("MONTHLY", "每月"),
YEARLY("YEARLY", "每年");
@Getter
private final String value;
@Getter
private final String desc;
}

View File

@@ -0,0 +1,20 @@
package com.starry.admin.modules.pk.setting.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
public enum PkScheduleDayOfWeek {
MONDAY("MONDAY", "周一"),
TUESDAY("TUESDAY", "周二"),
WEDNESDAY("WEDNESDAY", "周三"),
THURSDAY("THURSDAY", "周四"),
FRIDAY("FRIDAY", "周五"),
SATURDAY("SATURDAY", "周六"),
SUNDAY("SUNDAY", "周日");
@Getter
private final String value;
@Getter
private final String desc;
}

View File

@@ -0,0 +1,20 @@
package com.starry.admin.modules.pk.setting.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
public enum PkSettingErrorCode {
NOT_IMPLEMENTED("PK_SETTING_NOT_IMPLEMENTED", "PK排期设置功能尚未实现"),
TENANT_MISSING("PK_SETTING_TENANT_MISSING", "租户信息不能为空"),
SETTING_NOT_FOUND("PK_SETTING_NOT_FOUND", "PK排期设置不存在"),
REQUEST_INVALID("PK_SETTING_REQUEST_INVALID", "PK排期设置参数非法"),
RECURRENCE_INVALID("PK_SETTING_RECURRENCE_INVALID", "PK排期循环规则非法"),
TIME_RANGE_INVALID("PK_SETTING_TIME_RANGE_INVALID", "PK排期时间范围非法"),
CLERK_CONFLICT("PK_SETTING_CLERK_CONFLICT", "店员排期冲突");
@Getter
private final String code;
@Getter
private final String message;
}

View File

@@ -0,0 +1,15 @@
package com.starry.admin.modules.pk.setting.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
public enum PkSettingStatus {
ENABLED("ENABLED", "启用"),
DISABLED("DISABLED", "停用");
@Getter
private final String value;
@Getter
private final String desc;
}

View File

@@ -0,0 +1,7 @@
package com.starry.admin.modules.pk.setting.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity;
public interface PlayClerkPkSettingMapper extends BaseMapper<PlayClerkPkSettingEntity> {
}

View File

@@ -0,0 +1,27 @@
package com.starry.admin.modules.pk.setting.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest;
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest;
import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity;
import java.util.List;
public interface IPlayClerkPkSettingService extends IService<PlayClerkPkSettingEntity> {
String createSetting(PlayClerkPkSettingUpsertRequest request);
List<String> createSettings(PlayClerkPkSettingBulkCreateRequest request);
void updateSetting(String id, PlayClerkPkSettingUpsertRequest request);
void enableSetting(String id);
void disableSetting(String id);
PlayClerkPkSettingEntity getSetting(String id);
IPage<PlayClerkPkSettingEntity> listSettings();
int generateInstances(String id);
}

View File

@@ -0,0 +1,419 @@
package com.starry.admin.modules.pk.setting.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import com.starry.admin.modules.pk.setting.constants.PkSettingValidationConstants;
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest;
import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest;
import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity;
import com.starry.admin.modules.pk.setting.enums.PkRecurrenceType;
import com.starry.admin.modules.pk.setting.enums.PkScheduleDayOfWeek;
import com.starry.admin.modules.pk.setting.enums.PkSettingErrorCode;
import com.starry.admin.modules.pk.setting.enums.PkSettingStatus;
import com.starry.admin.modules.pk.setting.mapper.PlayClerkPkSettingMapper;
import com.starry.admin.modules.pk.setting.service.IPlayClerkPkSettingService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PlayClerkPkSettingServiceImpl
extends ServiceImpl<PlayClerkPkSettingMapper, PlayClerkPkSettingEntity>
implements IPlayClerkPkSettingService {
private final IPlayClerkPkService clerkPkService;
private final StringRedisTemplate stringRedisTemplate;
private static final String PAIR_KEY_SEPARATOR = "::";
public PlayClerkPkSettingServiceImpl(IPlayClerkPkService clerkPkService,
StringRedisTemplate stringRedisTemplate) {
this.clerkPkService = clerkPkService;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String createSetting(PlayClerkPkSettingUpsertRequest request) {
PlayClerkPkSettingEntity entity = buildEntity(request, null);
entity.setId(IdUtils.getUuid());
if (!save(entity)) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
if (PkSettingStatus.ENABLED.getValue().equals(entity.getStatus())) {
generateInstances(entity.getId());
}
return entity.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<String> createSettings(PlayClerkPkSettingBulkCreateRequest request) {
List<PlayClerkPkSettingUpsertRequest> settings = validateBulkRequest(request);
List<String> createdIds = new ArrayList<>();
for (PlayClerkPkSettingUpsertRequest settingRequest : settings) {
PlayClerkPkSettingEntity entity = buildEntity(settingRequest, null);
entity.setId(IdUtils.getUuid());
if (!save(entity)) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
if (PkSettingStatus.ENABLED.getValue().equals(entity.getStatus())) {
generateInstances(entity.getId());
}
createdIds.add(entity.getId());
}
return createdIds;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateSetting(String id, PlayClerkPkSettingUpsertRequest request) {
PlayClerkPkSettingEntity existing = getSetting(id);
PlayClerkPkSettingEntity entity = buildEntity(request, existing);
entity.setId(existing.getId());
entity.setTenantId(existing.getTenantId());
if (!updateById(entity)) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void enableSetting(String id) {
PlayClerkPkSettingEntity setting = getSetting(id);
setting.setStatus(PkSettingStatus.ENABLED.getValue());
updateById(setting);
generateInstances(id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void disableSetting(String id) {
PlayClerkPkSettingEntity setting = getSetting(id);
setting.setStatus(PkSettingStatus.DISABLED.getValue());
updateById(setting);
}
@Override
public PlayClerkPkSettingEntity getSetting(String id) {
if (StrUtil.isBlank(id)) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
PlayClerkPkSettingEntity setting = getById(id);
if (setting == null) {
throw new CustomException(PkSettingErrorCode.SETTING_NOT_FOUND.getMessage());
}
return setting;
}
@Override
public IPage<PlayClerkPkSettingEntity> listSettings() {
Page<PlayClerkPkSettingEntity> page = new Page<>(
PkSettingValidationConstants.DEFAULT_PAGE_NUMBER,
PkSettingValidationConstants.DEFAULT_PAGE_SIZE);
return baseMapper.selectPage(page, new LambdaQueryWrapper<>());
}
@Override
@Transactional(rollbackFor = Exception.class)
public int generateInstances(String id) {
PlayClerkPkSettingEntity setting = getSetting(id);
if (!PkSettingStatus.ENABLED.getValue().equals(setting.getStatus())) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
List<LocalDate> dates = generateScheduleDates(setting);
ZoneId zoneId = ZoneId.of(setting.getTimezone());
int createdCount = 0;
for (LocalDate date : dates) {
LocalDateTime startTime = LocalDateTime.of(date, setting.getStartTimeOfDay());
LocalDateTime endTime = startTime.plusMinutes(setting.getDurationMinutes());
Date beginTime = Date.from(startTime.atZone(zoneId).toInstant());
Date finishTime = Date.from(endTime.atZone(zoneId).toInstant());
if (existsForSettingAt(setting.getId(), beginTime)) {
continue;
}
if (hasClerkConflict(setting, beginTime, finishTime)) {
throw new CustomException(PkSettingErrorCode.CLERK_CONFLICT.getMessage());
}
PlayClerkPkEntity pk = new PlayClerkPkEntity();
pk.setId(IdUtils.getUuid());
pk.setTenantId(setting.getTenantId());
pk.setClerkA(setting.getClerkAId());
pk.setClerkB(setting.getClerkBId());
pk.setPkBeginTime(beginTime);
pk.setPkEndTime(finishTime);
pk.setStatus(ClerkPkEnum.TO_BE_STARTED.name());
pk.setSettled(0);
pk.setSettingId(setting.getId());
clerkPkService.save(pk);
scheduleStart(pk);
createdCount++;
}
return createdCount;
}
private PlayClerkPkSettingEntity buildEntity(PlayClerkPkSettingUpsertRequest request,
PlayClerkPkSettingEntity existing) {
validateRequest(request);
PlayClerkPkSettingEntity entity = Optional.ofNullable(existing).orElseGet(PlayClerkPkSettingEntity::new);
entity.setName(request.getName());
entity.setRecurrenceType(request.getRecurrenceType().getValue());
entity.setDayOfWeek(Optional.ofNullable(request.getDayOfWeek())
.map(PkScheduleDayOfWeek::getValue)
.orElse(null));
entity.setDayOfMonth(request.getDayOfMonth());
entity.setMonthOfYear(request.getMonthOfYear());
entity.setStartTimeOfDay(request.getStartTimeOfDay());
entity.setDurationMinutes(request.getDurationMinutes());
entity.setEffectiveStartDate(request.getEffectiveStartDate());
entity.setEffectiveEndDate(request.getEffectiveEndDate());
entity.setTimezone(request.getTimezone());
entity.setClerkAId(request.getClerkAId());
entity.setClerkBId(request.getClerkBId());
entity.setStatus(request.getStatus().getValue());
if (existing == null) {
entity.setTenantId(resolveTenantId());
}
return entity;
}
private void validateRequest(PlayClerkPkSettingUpsertRequest request) {
if (request == null) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
if (StrUtil.isBlank(request.getName())
|| request.getRecurrenceType() == null
|| request.getStartTimeOfDay() == null
|| request.getDurationMinutes() == null
|| request.getEffectiveStartDate() == null
|| request.getTimezone() == null
|| StrUtil.isBlank(request.getClerkAId())
|| StrUtil.isBlank(request.getClerkBId())
|| request.getStatus() == null) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
if (request.getDurationMinutes() < PkSettingValidationConstants.MIN_DURATION_MINUTES) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
if (request.getClerkAId().equals(request.getClerkBId())) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
ensureZoneValid(request.getTimezone());
validateRecurrenceFields(request);
validateEffectiveRange(request.getEffectiveStartDate(), request.getEffectiveEndDate());
}
private void scheduleStart(PlayClerkPkEntity pk) {
if (pk == null || pk.getPkBeginTime() == null || pk.getId() == null) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
if (StrUtil.isBlank(pk.getTenantId())) {
throw new CustomException(PkSettingErrorCode.TENANT_MISSING.getMessage());
}
String scheduleKey = PkRedisKeyConstants.startScheduleKey(pk.getTenantId());
long startEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond();
stringRedisTemplate.opsForZSet().add(scheduleKey, pk.getId(), startEpochSeconds);
}
private List<PlayClerkPkSettingUpsertRequest> validateBulkRequest(PlayClerkPkSettingBulkCreateRequest request) {
if (request == null || request.getSettings() == null || request.getSettings().isEmpty()) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
Set<String> pairKeys = new HashSet<>();
for (PlayClerkPkSettingUpsertRequest setting : request.getSettings()) {
validateRequest(setting);
String pairKey = buildPairKey(setting.getClerkAId(), setting.getClerkBId());
if (!pairKeys.add(pairKey)) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
}
return request.getSettings();
}
private String buildPairKey(String clerkAId, String clerkBId) {
if (clerkAId.compareTo(clerkBId) <= 0) {
return clerkAId + PAIR_KEY_SEPARATOR + clerkBId;
}
return clerkBId + PAIR_KEY_SEPARATOR + clerkAId;
}
private void ensureZoneValid(String timezone) {
try {
ZoneId.of(timezone);
} catch (Exception ex) {
throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage());
}
}
private void validateRecurrenceFields(PlayClerkPkSettingUpsertRequest request) {
PkRecurrenceType type = request.getRecurrenceType();
if (type == PkRecurrenceType.WEEKLY && request.getDayOfWeek() == null) {
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
}
if (type == PkRecurrenceType.MONTHLY && request.getDayOfMonth() == null) {
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
}
if (type == PkRecurrenceType.YEARLY
&& (request.getDayOfMonth() == null || request.getMonthOfYear() == null)) {
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
}
if (request.getDayOfMonth() != null) {
int dayOfMonth = request.getDayOfMonth();
if (dayOfMonth < PkSettingValidationConstants.MIN_DAY_OF_MONTH
|| dayOfMonth > PkSettingValidationConstants.MAX_DAY_OF_MONTH) {
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
}
}
if (request.getMonthOfYear() != null) {
int monthOfYear = request.getMonthOfYear();
if (monthOfYear < PkSettingValidationConstants.MIN_MONTH_OF_YEAR
|| monthOfYear > PkSettingValidationConstants.MAX_MONTH_OF_YEAR) {
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
}
}
}
private void validateEffectiveRange(LocalDate start, LocalDate end) {
if (end != null && end.isBefore(start)) {
throw new CustomException(PkSettingErrorCode.TIME_RANGE_INVALID.getMessage());
}
}
private List<LocalDate> generateScheduleDates(PlayClerkPkSettingEntity setting) {
LocalDate start = setting.getEffectiveStartDate();
LocalDate maxEnd = start.plusYears(PkSettingValidationConstants.MAX_YEARS_AHEAD);
LocalDate end = Optional.ofNullable(setting.getEffectiveEndDate()).orElse(maxEnd);
if (end.isAfter(maxEnd)) {
end = maxEnd;
}
PkRecurrenceType type = PkRecurrenceType.valueOf(setting.getRecurrenceType());
List<LocalDate> dates = new ArrayList<>();
switch (type) {
case ONCE:
if (!start.isAfter(end)) {
dates.add(start);
}
break;
case DAILY:
for (LocalDate cursor = start; !cursor.isAfter(end); cursor = cursor.plusDays(1)) {
dates.add(cursor);
}
break;
case WEEKLY:
dates.addAll(generateWeeklyDates(setting, start, end));
break;
case MONTHLY:
dates.addAll(generateMonthlyDates(setting, start, end));
break;
case YEARLY:
dates.addAll(generateYearlyDates(setting, start, end));
break;
default:
throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage());
}
return dates;
}
private List<LocalDate> generateWeeklyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) {
List<LocalDate> dates = new ArrayList<>();
DayOfWeek target = DayOfWeek.valueOf(setting.getDayOfWeek());
LocalDate cursor = start;
while (cursor.getDayOfWeek() != target) {
cursor = cursor.plusDays(1);
}
for (LocalDate date = cursor; !date.isAfter(end); date = date.plusWeeks(1)) {
dates.add(date);
}
return dates;
}
private List<LocalDate> generateMonthlyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) {
List<LocalDate> dates = new ArrayList<>();
int dayOfMonth = setting.getDayOfMonth();
YearMonth startMonth = YearMonth.from(start);
YearMonth endMonth = YearMonth.from(end);
for (YearMonth cursor = startMonth; !cursor.isAfter(endMonth); cursor = cursor.plusMonths(1)) {
if (dayOfMonth > cursor.lengthOfMonth()) {
continue;
}
LocalDate date = cursor.atDay(dayOfMonth);
if (date.isBefore(start) || date.isAfter(end)) {
continue;
}
dates.add(date);
}
return dates;
}
private List<LocalDate> generateYearlyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) {
List<LocalDate> dates = new ArrayList<>();
int dayOfMonth = setting.getDayOfMonth();
int monthOfYear = setting.getMonthOfYear();
for (int year = start.getYear(); year <= end.getYear(); year++) {
YearMonth yearMonth = YearMonth.of(year, monthOfYear);
if (dayOfMonth > yearMonth.lengthOfMonth()) {
continue;
}
LocalDate date = LocalDate.of(year, monthOfYear, dayOfMonth);
if (date.isBefore(start) || date.isAfter(end)) {
continue;
}
dates.add(date);
}
return dates;
}
private boolean existsForSettingAt(String settingId, Date beginTime) {
LambdaQueryWrapper<PlayClerkPkEntity> wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class)
.eq(PlayClerkPkEntity::getSettingId, settingId)
.eq(PlayClerkPkEntity::getPkBeginTime, beginTime);
return clerkPkService.count(wrapper) > 0;
}
private boolean hasClerkConflict(PlayClerkPkSettingEntity setting, Date beginTime, Date endTime) {
LambdaQueryWrapper<PlayClerkPkEntity> wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class)
.in(PlayClerkPkEntity::getStatus,
ClerkPkEnum.TO_BE_STARTED.name(),
ClerkPkEnum.IN_PROGRESS.name())
.and(time -> time.le(PlayClerkPkEntity::getPkBeginTime, endTime)
.ge(PlayClerkPkEntity::getPkEndTime, beginTime))
.and(clerk -> clerk.eq(PlayClerkPkEntity::getClerkA, setting.getClerkAId())
.or()
.eq(PlayClerkPkEntity::getClerkB, setting.getClerkAId())
.or()
.eq(PlayClerkPkEntity::getClerkA, setting.getClerkBId())
.or()
.eq(PlayClerkPkEntity::getClerkB, setting.getClerkBId()));
return clerkPkService.count(wrapper) > 0;
}
private String resolveTenantId() {
String tenantId = SecurityUtils.getTenantId();
if (StrUtil.isBlank(tenantId)) {
throw new CustomException(PkSettingErrorCode.TENANT_MISSING.getMessage());
}
return tenantId;
}
}

View File

@@ -1,9 +1,12 @@
package com.starry.admin.modules.shop.mapper; package com.starry.admin.modules.shop.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.github.yulichang.base.MPJBaseMapper; import com.github.yulichang.base.MPJBaseMapper;
import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity; import com.starry.admin.modules.shop.module.entity.PlayCommodityInfoEntity;
import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo; import com.starry.admin.modules.shop.module.vo.PlayCommodityInfoVo;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** /**
* 服务项目Mapper接口 * 服务项目Mapper接口
@@ -34,4 +37,12 @@ public interface PlayCommodityInfoMapper extends MPJBaseMapper<PlayCommodityInfo
@Select("select t.id as commodityId,t3.price as commodityPrice,t.item_name as serviceDuration,t1.item_name as commodityName from play_commodity_info t left join play_commodity_info t1 on t.p_id = t1.id left join play_commodity_and_level_info t3 ON t3.commodity_id = t.id where t3.price is not null and t.id = #{id} and t3.level_id = #{levelId} limit 1") @Select("select t.id as commodityId,t3.price as commodityPrice,t.item_name as serviceDuration,t1.item_name as commodityName from play_commodity_info t left join play_commodity_info t1 on t.p_id = t1.id left join play_commodity_and_level_info t3 ON t3.commodity_id = t.id where t3.price is not null and t.id = #{id} and t3.level_id = #{levelId} limit 1")
PlayCommodityInfoVo queryCommodityInfo(String id, String levelId); PlayCommodityInfoVo queryCommodityInfo(String id, String levelId);
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT id, tenant_id, deleted FROM play_commodity_info WHERE id = #{id} LIMIT 1")
PlayCommodityInfoEntity selectByIdIncludingDeleted(@Param("id") String id);
@InterceptorIgnore(tenantLine = "true")
@Update("UPDATE play_commodity_info SET deleted = 0, tenant_id = #{tenantId}, enable_stace = '1' WHERE id = #{id}")
int restoreCommodity(@Param("id") String id, @Param("tenantId") String tenantId);
} }

View File

@@ -226,12 +226,12 @@ public class PlayClerkPerformanceController {
customIds.add(orderInfoEntity.getPurchaserBy()); customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount()); finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount());
orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney()); orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney());
if ("1".equals(orderInfoEntity.getFirstOrder())) { // if ("1".equals(orderInfoEntity.getFirstOrder())) {
orderFirstAmount = orderFirstAmount.add(orderInfoEntity.getFinalAmount()); // orderFirstAmount = orderFirstAmount.add(orderInfoEntity.getFinalAmount());
} else { // } else {
orderContinueNumber++; // orderContinueNumber++;
orderTotalAmount = orderTotalAmount.add(orderInfoEntity.getFinalAmount()); // orderTotalAmount = orderTotalAmount.add(orderInfoEntity.getFinalAmount());
} // }
if ("2".equals(orderInfoEntity.getPlaceType())) { if ("2".equals(orderInfoEntity.getPlaceType())) {
orderRewardAmount = orderRewardAmount.add(orderInfoEntity.getFinalAmount()); orderRewardAmount = orderRewardAmount.add(orderInfoEntity.getFinalAmount());
} }
@@ -303,12 +303,12 @@ public class PlayClerkPerformanceController {
customIds.add(orderInfoEntity.getPurchaserBy()); customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount()); finalAmount = finalAmount.add(orderInfoEntity.getFinalAmount());
orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney()); orderMoney = orderMoney.add(orderInfoEntity.getOrderMoney());
if ("1".equals(orderInfoEntity.getFirstOrder())) { // if ("1".equals(orderInfoEntity.getFirstOrder())) {
orderFirstAmount = orderFirstAmount.add(orderInfoEntity.getFinalAmount()); // orderFirstAmount = orderFirstAmount.add(orderInfoEntity.getFinalAmount());
} else { // } else {
orderContinueNumber++; // orderContinueNumber++;
orderTotalAmount = orderTotalAmount.add(orderInfoEntity.getFinalAmount()); // orderTotalAmount = orderTotalAmount.add(orderInfoEntity.getFinalAmount());
} // }
if ("2".equals(orderInfoEntity.getPlaceType())) { if ("2".equals(orderInfoEntity.getPlaceType())) {
orderRewardAmount = orderRewardAmount.add(orderInfoEntity.getFinalAmount()); orderRewardAmount = orderRewardAmount.add(orderInfoEntity.getFinalAmount());
} }

View File

@@ -31,6 +31,9 @@ public class ClerkPerformanceOverviewQueryVo extends PlayClerkPerformanceInfoQue
@ApiModelProperty(value = "是否包含排行列表数据") @ApiModelProperty(value = "是否包含排行列表数据")
private Boolean includeRankings = Boolean.TRUE; private Boolean includeRankings = Boolean.TRUE;
@ApiModelProperty(value = "是否包含收益调整ADJUSTMENT", allowableValues = "true,false")
private Boolean includeAdjustments = Boolean.FALSE;
@Override @Override
public void setEndOrderTime(List<String> endOrderTime) { public void setEndOrderTime(List<String> endOrderTime) {
super.setEndOrderTime(endOrderTime); super.setEndOrderTime(endOrderTime);

View File

@@ -33,11 +33,9 @@ import com.starry.admin.utils.SecurityUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -91,12 +89,12 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
completedOrderCount++; completedOrderCount++;
customIds.add(orderInfoEntity.getPurchaserBy()); customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); finalAmount = finalAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
if (OrderConstant.YesNoFlag.YES.getCode().equals(orderInfoEntity.getFirstOrder())) { // if (OrderConstant.YesNoFlag.YES.getCode().equals(orderInfoEntity.getFirstOrder())) {
orderFirstAmount = orderFirstAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); // orderFirstAmount = orderFirstAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
} else { // } else {
orderContinueNumber++; // orderContinueNumber++;
orderTotalAmount = orderTotalAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); // orderTotalAmount = orderTotalAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
} // }
if (OrderConstant.PlaceType.REWARD.getCode().equals(orderInfoEntity.getPlaceType())) { if (OrderConstant.PlaceType.REWARD.getCode().equals(orderInfoEntity.getPlaceType())) {
orderRewardAmount = orderRewardAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); orderRewardAmount = orderRewardAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
} }
@@ -144,6 +142,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) { public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) {
DateRange range = resolveDateRange(vo.getEndOrderTime()); DateRange range = resolveDateRange(vo.getEndOrderTime());
List<PlayClerkUserInfoEntity> clerks = loadAccessibleClerks(vo); List<PlayClerkUserInfoEntity> clerks = loadAccessibleClerks(vo);
boolean includeAdjustments = Boolean.TRUE.equals(vo.getIncludeAdjustments());
ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo(); ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo();
if (CollectionUtil.isEmpty(clerks)) { if (CollectionUtil.isEmpty(clerks)) {
responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo()); responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo());
@@ -160,7 +159,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
for (PlayClerkUserInfoEntity clerk : clerks) { for (PlayClerkUserInfoEntity clerk : clerks) {
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(), List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
range.startTime, range.endTime); range.startTime, range.endTime);
snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime)); snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime, includeAdjustments));
} }
int total = snapshots.size(); int total = snapshots.size();
ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots); ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots);
@@ -196,13 +195,13 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(), List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
range.startTime, range.endTime); range.startTime, range.endTime);
ClerkPerformanceSnapshotVo snapshot = ClerkPerformanceSnapshotVo snapshot =
buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime); buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime, false);
ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo(); ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo();
responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap)); responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap));
responseVo.setSnapshot(snapshot); responseVo.setSnapshot(snapshot);
responseVo.setComposition(buildComposition(snapshot)); responseVo.setComposition(buildComposition(snapshot));
if (Boolean.TRUE.equals(vo.getIncludeTrend())) { if (Boolean.TRUE.equals(vo.getIncludeTrend())) {
responseVo.setTrend(buildTrend(orders, range, responseVo.setTrend(buildTrend(clerk.getId(), orders, range,
vo.getTrendDays() == null || vo.getTrendDays() <= 0 ? 7 : vo.getTrendDays())); vo.getTrendDays() == null || vo.getTrendDays() <= 0 ? 7 : vo.getTrendDays()));
responseVo.setTrendDimension("DAY"); responseVo.setTrendDimension("DAY");
} else { } else {
@@ -226,8 +225,8 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
return profile; return profile;
} }
private List<ClerkPerformanceTrendPointVo> buildTrend(List<PlayOrderInfoEntity> orders, DateRange range, private List<ClerkPerformanceTrendPointVo> buildTrend(String clerkId, List<PlayOrderInfoEntity> orders,
int trendDays) { DateRange range, int trendDays) {
List<PlayOrderInfoEntity> completedOrders = orders.stream() List<PlayOrderInfoEntity> completedOrders = orders.stream()
.filter(this::isCompletedOrder) .filter(this::isCompletedOrder)
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -277,11 +276,12 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
for (PlayOrderInfoEntity order : orders) { for (PlayOrderInfoEntity order : orders) {
BigDecimal finalAmount = defaultZero(order.getFinalAmount()); BigDecimal finalAmount = defaultZero(order.getFinalAmount());
gmv = gmv.add(finalAmount); gmv = gmv.add(finalAmount);
if (OrderConstant.YesNoFlag.YES.getCode().equals(order.getFirstOrder())) { OrderConstant.OrderRelationType relationType = normalizeRelationType(order);
firstAmount = firstAmount.add(finalAmount); if (relationType == OrderConstant.OrderRelationType.CONTINUED) {
} else {
continuedAmount = continuedAmount.add(finalAmount); continuedAmount = continuedAmount.add(finalAmount);
continuedCount++; continuedCount++;
} else {
firstAmount = firstAmount.add(finalAmount);
} }
} }
point.setGmv(gmv); point.setGmv(gmv);
@@ -425,26 +425,64 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
private BigDecimal calculateEarningsAmount(String clerkId, List<PlayOrderInfoEntity> orders, String startTime, private BigDecimal calculateEarningsAmount(String clerkId, List<PlayOrderInfoEntity> orders, String startTime,
String endTime) { String endTime) {
if (StrUtil.isBlank(clerkId) || CollectionUtil.isEmpty(orders)) { return calculateEarningsAmount(SecurityUtils.getTenantId(), clerkId, orders, startTime, endTime, false);
return BigDecimal.ZERO; }
}
List<String> orderIds = orders.stream() private BigDecimal calculateEarningsAmount(
.filter(this::isCompletedOrder) String tenantId,
.map(PlayOrderInfoEntity::getId) String clerkId,
.filter(StrUtil::isNotBlank) List<PlayOrderInfoEntity> orders,
.collect(Collectors.toList()); String startTime,
if (CollectionUtil.isEmpty(orderIds)) { String endTime,
boolean includeAdjustments) {
if (StrUtil.isBlank(clerkId)) {
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime); String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime);
String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime); String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime);
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
normalizedEnd); BigDecimal orderSum = BigDecimal.ZERO;
return defaultZero(sum); if (CollectionUtil.isNotEmpty(orders)) {
List<String> orderIds = orders.stream()
.filter(this::isCompletedOrder)
.map(PlayOrderInfoEntity::getId)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
if (CollectionUtil.isNotEmpty(orderIds)) {
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
normalizedEnd);
orderSum = defaultZero(sum);
}
}
if (!includeAdjustments) {
return orderSum;
}
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(tenantId, clerkId, normalizedStart, normalizedEnd);
return orderSum.add(defaultZero(adjustmentSum));
}
private OrderConstant.OrderRelationType normalizeRelationType(PlayOrderInfoEntity order) {
if (order == null) {
throw new ServiceException("订单不能为空,无法统计首单/续单");
}
OrderConstant.OrderRelationType relationType = order.getOrderRelationType();
if (relationType == null) {
throw new ServiceException("订单关系类型不能为空,无法统计首单/续单");
}
if (OrderConstant.PlaceType.RANDOM.getCode().equals(order.getPlaceType())) {
return OrderConstant.OrderRelationType.FIRST;
}
if (relationType == OrderConstant.OrderRelationType.NEUTRAL) {
return OrderConstant.OrderRelationType.FIRST;
}
return relationType;
} }
private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List<PlayOrderInfoEntity> orders, private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List<PlayOrderInfoEntity> orders,
Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime) { Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime,
boolean includeAdjustments) {
ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo(); ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo();
snapshot.setClerkId(clerk.getId()); snapshot.setClerkId(clerk.getId());
snapshot.setClerkNickname(clerk.getNickname()); snapshot.setClerkNickname(clerk.getNickname());
@@ -461,23 +499,20 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
int continuedCount = 0; int continuedCount = 0;
int refundCount = 0; int refundCount = 0;
int expiredCount = 0; int expiredCount = 0;
Map<String, Integer> userOrderMap = new HashMap<>(); List<PlayOrderInfoEntity> completedOrders = orders.stream()
int orderCount = 0; .filter(this::isCompletedOrder)
for (PlayOrderInfoEntity order : orders) { .collect(Collectors.toList());
if (!isCompletedOrder(order)) { int orderCount = completedOrders.size();
continue; Set<String> userIds = new HashSet<>();
} Set<String> continuedUserIds = new HashSet<>();
orderCount++; for (PlayOrderInfoEntity order : completedOrders) {
BigDecimal finalAmount = defaultZero(order.getFinalAmount()); BigDecimal finalAmount = defaultZero(order.getFinalAmount());
gmv = gmv.add(finalAmount); gmv = gmv.add(finalAmount);
userOrderMap.merge(order.getPurchaserBy(), 1, Integer::sum); String customerId = order.getPurchaserBy();
if (OrderConstant.YesNoFlag.YES.getCode().equals(order.getFirstOrder())) { if (StrUtil.isBlank(customerId)) {
firstCount++; throw new ServiceException("订单缺少顾客信息,无法统计首单/续单");
firstAmount = firstAmount.add(finalAmount);
} else {
continuedCount++;
continuedAmount = continuedAmount.add(finalAmount);
} }
userIds.add(customerId);
if (OrderConstant.PlaceType.REWARD.getCode().equals(order.getPlaceType())) { if (OrderConstant.PlaceType.REWARD.getCode().equals(order.getPlaceType())) {
rewardAmount = rewardAmount.add(finalAmount); rewardAmount = rewardAmount.add(finalAmount);
} }
@@ -488,10 +523,19 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
if (OrderConstant.OrdersExpiredState.EXPIRED.getCode().equals(order.getOrdersExpiredState())) { if (OrderConstant.OrdersExpiredState.EXPIRED.getCode().equals(order.getOrdersExpiredState())) {
expiredCount++; expiredCount++;
} }
OrderConstant.OrderRelationType relationType = normalizeRelationType(order);
if (relationType == OrderConstant.OrderRelationType.CONTINUED) {
continuedCount++;
continuedAmount = continuedAmount.add(finalAmount);
continuedUserIds.add(customerId);
} else {
firstCount++;
firstAmount = firstAmount.add(finalAmount);
}
} }
int userCount = userOrderMap.size(); int userCount = userIds.size();
int continuedUserCount = (int) userOrderMap.values().stream().filter(cnt -> cnt > 1).count(); int continuedUserCount = continuedUserIds.size();
BigDecimal estimatedRevenue = calculateEarningsAmount(clerk.getId(), orders, startTime, endTime); BigDecimal estimatedRevenue = calculateEarningsAmount(SecurityUtils.getTenantId(), clerk.getId(), orders, startTime, endTime, includeAdjustments);
snapshot.setGmv(gmv); snapshot.setGmv(gmv);
snapshot.setFirstOrderAmount(firstAmount); snapshot.setFirstOrderAmount(firstAmount);
snapshot.setContinuedOrderAmount(continuedAmount); snapshot.setContinuedOrderAmount(continuedAmount);

View File

@@ -17,6 +17,7 @@ import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -40,6 +41,9 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/wx/commodity/") @RequestMapping("/wx/commodity/")
public class WxClerkCommodityController { public class WxClerkCommodityController {
private static final String ROOT_PARENT_ID = "00";
private static final String CLERK_COMMODITY_ENABLED = "1";
@Resource @Resource
private IPlayCommodityInfoService playCommodityInfoService; private IPlayCommodityInfoService playCommodityInfoService;
@@ -63,6 +67,12 @@ public class WxClerkCommodityController {
if (levelId == null || levelId.isEmpty()) { if (levelId == null || levelId.isEmpty()) {
return R.ok(tree); return R.ok(tree);
} }
if (levelInfoEntities == null) {
throw new CustomException("商品等级信息缺失");
}
if (tree == null) {
throw new CustomException("商品树缺失");
}
tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, levelId); tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, levelId);
tree = formatPlayCommodityReturnVoTree(tree, null); tree = formatPlayCommodityReturnVoTree(tree, null);
return R.ok(tree); return R.ok(tree);
@@ -84,11 +94,23 @@ public class WxClerkCommodityController {
throw new CustomException("请求参数异常,id不能为空"); throw new CustomException("请求参数异常,id不能为空");
} }
PlayClerkUserInfoEntity clerkUserInfo = clerkUserInfoService.selectById(clerkId); PlayClerkUserInfoEntity clerkUserInfo = clerkUserInfoService.selectById(clerkId);
if (clerkUserInfo == null) {
throw new CustomException("店员不存在");
}
if (clerkUserInfo.getLevelId() == null || clerkUserInfo.getLevelId().isEmpty()) {
throw new CustomException("店员等级信息缺失");
}
Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities = playClerkCommodityService Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities = playClerkCommodityService
.selectCommodityTypeByUser(clerkId, "1").stream() .selectCommodityTypeByUser(clerkId, CLERK_COMMODITY_ENABLED).stream()
.collect(Collectors.groupingBy(PlayClerkCommodityEntity::getCommodityId)); .collect(Collectors.groupingBy(PlayClerkCommodityEntity::getCommodityId));
List<PlayCommodityAndLevelInfoEntity> levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll(); List<PlayCommodityAndLevelInfoEntity> levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll();
List<PlayCommodityReturnVo> tree = playCommodityInfoService.selectTree(); List<PlayCommodityReturnVo> tree = playCommodityInfoService.selectTree();
if (levelInfoEntities == null) {
throw new CustomException("商品等级信息缺失");
}
if (tree == null) {
throw new CustomException("商品树缺失");
}
tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, clerkUserInfo.getLevelId()); tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, clerkUserInfo.getLevelId());
tree = formatPlayCommodityReturnVoTree(tree, clerkCommodityEntities); tree = formatPlayCommodityReturnVoTree(tree, clerkCommodityEntities);
return R.ok(tree); return R.ok(tree);
@@ -108,10 +130,16 @@ public class WxClerkCommodityController {
String levelId = ThreadLocalRequestDetail.getClerkUserInfo().getLevelId(); String levelId = ThreadLocalRequestDetail.getClerkUserInfo().getLevelId();
List<PlayCommodityAndLevelInfoEntity> levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll(); List<PlayCommodityAndLevelInfoEntity> levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll();
Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities = playClerkCommodityService Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities = playClerkCommodityService
.selectCommodityTypeByUser(ThreadLocalRequestDetail.getClerkUserInfo().getId(), "1").stream() .selectCommodityTypeByUser(ThreadLocalRequestDetail.getClerkUserInfo().getId(), CLERK_COMMODITY_ENABLED).stream()
.collect(Collectors.groupingBy(PlayClerkCommodityEntity::getCommodityId)); .collect(Collectors.groupingBy(PlayClerkCommodityEntity::getCommodityId));
List<PlayCommodityReturnVo> tree = playCommodityInfoService.selectTree(); List<PlayCommodityReturnVo> tree = playCommodityInfoService.selectTree();
if (levelInfoEntities == null) {
throw new CustomException("商品等级信息缺失");
}
if (tree == null) {
throw new CustomException("商品树缺失");
}
tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, levelId); tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, levelId);
tree = formatPlayCommodityReturnVoTree(tree, clerkCommodityEntities); tree = formatPlayCommodityReturnVoTree(tree, clerkCommodityEntities);
return R.ok(tree); return R.ok(tree);
@@ -119,9 +147,21 @@ public class WxClerkCommodityController {
public List<PlayCommodityReturnVo> formatPlayCommodityReturnVoTree(List<PlayCommodityReturnVo> tree, public List<PlayCommodityReturnVo> formatPlayCommodityReturnVoTree(List<PlayCommodityReturnVo> tree,
List<PlayCommodityAndLevelInfoEntity> levelInfoEntities, String levelId) { List<PlayCommodityAndLevelInfoEntity> levelInfoEntities, String levelId) {
if (tree == null) {
throw new CustomException("商品树缺失");
}
if (levelInfoEntities == null) {
throw new CustomException("商品等级信息缺失");
}
if (levelId == null || levelId.isEmpty()) {
throw new CustomException("等级信息缺失");
}
Iterator<PlayCommodityReturnVo> it = tree.iterator(); Iterator<PlayCommodityReturnVo> it = tree.iterator();
while (it.hasNext()) { while (it.hasNext()) {
PlayCommodityReturnVo item = it.next(); PlayCommodityReturnVo item = it.next();
if (item.getChild() == null) {
item.setChild(new ArrayList<>());
}
// 查找当前服务项目对应的价格 // 查找当前服务项目对应的价格
for (PlayCommodityAndLevelInfoEntity levelInfoEntity : levelInfoEntities) { for (PlayCommodityAndLevelInfoEntity levelInfoEntity : levelInfoEntities) {
if (item.getId().equals(levelInfoEntity.getCommodityId()) if (item.getId().equals(levelInfoEntity.getCommodityId())
@@ -130,7 +170,7 @@ public class WxClerkCommodityController {
} }
} }
// 如果未设置价格,删除元素 // 如果未设置价格,删除元素
if (!"00".equals(item.getPId()) && item.getPrice() == null) { if (!ROOT_PARENT_ID.equals(item.getPId()) && item.getPrice() == null) {
it.remove(); it.remove();
} }
formatPlayCommodityReturnVoTree(item.getChild(), levelInfoEntities, levelId); formatPlayCommodityReturnVoTree(item.getChild(), levelInfoEntities, levelId);
@@ -140,12 +180,18 @@ public class WxClerkCommodityController {
public List<PlayCommodityReturnVo> formatPlayCommodityReturnVoTree(List<PlayCommodityReturnVo> tree, public List<PlayCommodityReturnVo> formatPlayCommodityReturnVoTree(List<PlayCommodityReturnVo> tree,
Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities) { Map<String, List<PlayClerkCommodityEntity>> clerkCommodityEntities) {
if (tree == null) {
throw new CustomException("商品树缺失");
}
Iterator<PlayCommodityReturnVo> it = tree.iterator(); Iterator<PlayCommodityReturnVo> it = tree.iterator();
while (it.hasNext()) { while (it.hasNext()) {
PlayCommodityReturnVo item = it.next(); PlayCommodityReturnVo item = it.next();
if ("00".equals(item.getPId()) && item.getChild().isEmpty()) { if (item.getChild() == null) {
item.setChild(new ArrayList<>());
}
if (ROOT_PARENT_ID.equals(item.getPId()) && item.getChild().isEmpty()) {
it.remove(); it.remove();
} else if (clerkCommodityEntities != null && "00".equals(item.getPId()) } else if (clerkCommodityEntities != null && ROOT_PARENT_ID.equals(item.getPId())
&& !clerkCommodityEntities.containsKey(item.getId())) { && !clerkCommodityEntities.containsKey(item.getId())) {
it.remove(); it.remove();
} }

View File

@@ -235,7 +235,7 @@ public class WxCustomController {
.orderType(OrderConstant.OrderType.NORMAL) .orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.REWARD) .placeType(OrderConstant.PlaceType.REWARD)
.rewardType(RewardType.BALANCE) .rewardType(RewardType.BALANCE)
.isFirstOrder(false) .orderRelationType(OrderConstant.OrderRelationType.UNASSIGNED)
.creatorActor(OrderActor.CUSTOMER) .creatorActor(OrderActor.CUSTOMER)
.creatorId(userId) .creatorId(userId)
.commodityInfo(CommodityInfo.builder() .commodityInfo(CommodityInfo.builder()
@@ -304,7 +304,7 @@ public class WxCustomController {
.orderType(OrderConstant.OrderType.NORMAL) .orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED) .placeType(OrderConstant.PlaceType.SPECIFIED)
.rewardType(RewardType.NOT_APPLICABLE) .rewardType(RewardType.NOT_APPLICABLE)
.isFirstOrder(true) .orderRelationType(OrderConstant.OrderRelationType.UNASSIGNED)
.creatorActor(OrderActor.CUSTOMER) .creatorActor(OrderActor.CUSTOMER)
.creatorId(customId) .creatorId(customId)
.commodityInfo(CommodityInfo.builder() .commodityInfo(CommodityInfo.builder()
@@ -376,7 +376,7 @@ public class WxCustomController {
.orderType(OrderConstant.OrderType.NORMAL) .orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.RANDOM) .placeType(OrderConstant.PlaceType.RANDOM)
.rewardType(RewardType.NOT_APPLICABLE) .rewardType(RewardType.NOT_APPLICABLE)
.isFirstOrder(true) .orderRelationType(OrderConstant.OrderRelationType.FIRST)
.creatorActor(OrderActor.CUSTOMER) .creatorActor(OrderActor.CUSTOMER)
.creatorId(customId) .creatorId(customId)
.commodityInfo(CommodityInfo.builder() .commodityInfo(CommodityInfo.builder()

View File

@@ -122,25 +122,27 @@ public class WxOrderInfoController {
if (vo == null) { if (vo == null) {
throw new CustomException("订单不存在"); throw new CustomException("订单不存在");
} }
// Privacy protection: Hide customer info for pending random orders that current clerk hasn't accepted
String currentClerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId(); String currentClerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
if (OrderConstant.PlaceType.RANDOM.getCode().equals(vo.getPlaceType()) && OrderConstant.OrderStatus.PENDING.getCode().equals(vo.getOrderStatus())) { if (OrderConstant.PlaceType.RANDOM.getCode().equals(vo.getPlaceType())) {
// Random order pending - customer info already hidden by service layer boolean acceptedByCurrentClerk = StringUtils.isNotEmpty(vo.getAcceptBy())
vo.setWeiChatCode(""); && vo.getAcceptBy().equals(currentClerkId);
} else if (StringUtils.isNotEmpty(vo.getAcceptBy()) && !vo.getAcceptBy().equals(currentClerkId)) { if (!acceptedByCurrentClerk) {
// Order accepted by another clerk - hide WeChat and customer info maskRandomOrderForNonOwner(vo, currentClerkId);
vo.setWeiChatCode(""); }
vo.setCustomNickname("匿名用户");
vo.setCustomAvatar("");
vo.setCustomId("");
}
if(vo.getOrderStatus().equals("4")){
vo.setWeiChatCode("");
} }
return R.ok(vo); return R.ok(vo);
} }
private void maskRandomOrderForNonOwner(PlayOrderDetailsReturnVo vo, String currentClerkId) {
vo.setWeiChatCode("");
vo.setCustomNickname("匿名用户");
vo.setCustomAvatar("");
vo.setCustomId("");
if (StringUtils.isNotEmpty(vo.getAcceptBy()) && !vo.getAcceptBy().equals(currentClerkId)) {
vo.setOrderStatus("");
}
}
/** /**
* 店员查询打赏动态 * 店员查询打赏动态
* *

View File

@@ -0,0 +1,279 @@
package com.starry.admin.modules.weichat.controller;
import cn.hutool.core.util.StrUtil;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.pk.constants.PkWxQueryConstants;
import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto;
import com.starry.admin.modules.pk.dto.WxPkDetailDto;
import com.starry.admin.modules.pk.dto.WxPkLiveDto;
import com.starry.admin.modules.pk.dto.WxPkUpcomingDto;
import com.starry.admin.modules.pk.enums.PkWxState;
import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
import com.starry.admin.modules.pk.service.IPkDetailService;
import com.starry.admin.modules.pk.service.IPkScoreboardService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.R;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Api(tags = "微信PK接口", description = "微信端PK展示相关接口")
@RestController
@RequestMapping("/wx/pk")
public class WxPkController {
private final IPlayClerkPkService clerkPkService;
private final IPlayClerkUserInfoService clerkUserInfoService;
private final IPkScoreboardService pkScoreboardService;
private final IPkDetailService pkDetailService;
private final StringRedisTemplate stringRedisTemplate;
public WxPkController(IPlayClerkPkService clerkPkService,
IPlayClerkUserInfoService clerkUserInfoService,
IPkScoreboardService pkScoreboardService,
IPkDetailService pkDetailService,
StringRedisTemplate stringRedisTemplate) {
this.clerkPkService = clerkPkService;
this.clerkUserInfoService = clerkUserInfoService;
this.pkScoreboardService = pkScoreboardService;
this.pkDetailService = pkDetailService;
this.stringRedisTemplate = stringRedisTemplate;
}
@ApiOperation(value = "店员PK实时状态", notes = "查询指定店员是否正在PK")
@GetMapping("/clerk/live")
public R getClerkLive(@RequestParam("clerkId") String clerkId) {
if (StrUtil.isBlank(clerkId)) {
throw new CustomException("店员ID不能为空");
}
Optional<PlayClerkPkEntity> pkOptional =
clerkPkService.findActivePkForClerk(clerkId, LocalDateTime.now());
if (!pkOptional.isPresent()) {
return R.ok(WxPkLiveDto.inactive());
}
PlayClerkPkEntity pk = pkOptional.get();
if (!ClerkPkEnum.IN_PROGRESS.name().equals(pk.getStatus())) {
return R.ok(WxPkLiveDto.inactive());
}
PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pk.getId());
return R.ok(buildLiveDto(pk, scoreboard));
}
@ApiOperation(value = "即将开战PK", notes = "返回即将开始的PK信息")
@GetMapping("/upcoming")
public R getUpcoming() {
String tenantId = SecurityUtils.getTenantId();
if (StrUtil.isBlank(tenantId)) {
throw new CustomException("租户信息缺失");
}
String key = PkRedisKeyConstants.upcomingKey(tenantId);
long nowEpochSeconds = Instant.now().getEpochSecond();
Set<String> pkIds = stringRedisTemplate.opsForZSet()
.rangeByScore(key, nowEpochSeconds, Double.POSITIVE_INFINITY, 0, 1);
if (pkIds == null || pkIds.isEmpty()) {
return R.ok(WxPkUpcomingDto.inactive());
}
String pkId = pkIds.iterator().next();
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
if (pk == null) {
return R.ok(WxPkUpcomingDto.inactive());
}
return R.ok(buildUpcomingDto(pk));
}
@ApiOperation(value = "PK详情", notes = "查询PK详情用于详情页")
@GetMapping("/detail")
public R getDetail(@RequestParam("id") String id) {
if (StrUtil.isBlank(id)) {
throw new CustomException("PK ID不能为空");
}
PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(id);
if (pk == null) {
return R.ok(WxPkDetailDto.inactive());
}
PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pk.getId());
return R.ok(buildDetailDto(pk, scoreboard));
}
@ApiOperation(value = "店员PK历史", notes = "查询指定店员PK历史")
@GetMapping("/clerk/history")
public R getClerkHistory(@RequestParam("clerkId") String clerkId,
@RequestParam(value = "pageNum", required = false) Integer pageNum,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
if (StrUtil.isBlank(clerkId)) {
throw new CustomException("店员ID不能为空");
}
int safePageNum = pageNum == null ? PkWxQueryConstants.CLERK_HISTORY_PAGE_NUM : pageNum;
int safePageSize = pageSize == null ? PkWxQueryConstants.CLERK_HISTORY_PAGE_SIZE : pageSize;
WxPkClerkHistoryPageDto data = pkDetailService.getClerkHistory(clerkId, safePageNum, safePageSize);
return R.ok(data);
}
@ApiOperation(value = "店员PK日程", notes = "查询指定店员未来PK安排")
@GetMapping("/clerk/schedule")
public R getClerkSchedule(@RequestParam("clerkId") String clerkId,
@RequestParam(value = "limit", required = false) Integer limit) {
if (StrUtil.isBlank(clerkId)) {
throw new CustomException("店员ID不能为空");
}
String tenantId = SecurityUtils.getTenantId();
if (StrUtil.isBlank(tenantId)) {
throw new CustomException("租户信息缺失");
}
int safeLimit = normalizeLimit(limit);
List<PlayClerkPkEntity> items = clerkPkService.selectUpcomingForClerk(
tenantId,
clerkId,
new Date(),
safeLimit);
if (items == null || items.isEmpty()) {
return R.ok(new ArrayList<>());
}
List<WxPkUpcomingDto> result = new ArrayList<>();
for (PlayClerkPkEntity pk : items) {
result.add(buildUpcomingDto(pk));
}
return R.ok(result);
}
private WxPkLiveDto buildLiveDto(PlayClerkPkEntity pk, PkScoreBoardDto scoreboard) {
WxPkLiveDto dto = new WxPkLiveDto();
fillBase(dto, pk);
dto.setState(PkWxState.ACTIVE.getValue());
dto.setClerkAScore(scoreboard.getClerkAScore());
dto.setClerkBScore(scoreboard.getClerkBScore());
dto.setClerkAOrderCount(scoreboard.getClerkAOrderCount());
dto.setClerkBOrderCount(scoreboard.getClerkBOrderCount());
dto.setRemainingSeconds(scoreboard.getRemainingSeconds());
dto.setServerEpochSeconds(Instant.now().getEpochSecond());
if (pk.getPkEndTime() != null) {
dto.setPkEndEpochSeconds(pk.getPkEndTime().toInstant().getEpochSecond());
}
return dto;
}
private WxPkUpcomingDto buildUpcomingDto(PlayClerkPkEntity pk) {
WxPkUpcomingDto dto = new WxPkUpcomingDto();
fillBase(dto, pk);
dto.setState(PkWxState.UPCOMING.getValue());
dto.setPkBeginTime(pk.getPkBeginTime());
return dto;
}
private WxPkDetailDto buildDetailDto(PlayClerkPkEntity pk, PkScoreBoardDto scoreboard) {
WxPkDetailDto dto = new WxPkDetailDto();
fillBase(dto, pk);
dto.setState(resolveState(pk.getStatus()));
dto.setClerkAScore(scoreboard.getClerkAScore());
dto.setClerkBScore(scoreboard.getClerkBScore());
dto.setClerkAOrderCount(scoreboard.getClerkAOrderCount());
dto.setClerkBOrderCount(scoreboard.getClerkBOrderCount());
dto.setRemainingSeconds(scoreboard.getRemainingSeconds());
dto.setPkBeginTime(pk.getPkBeginTime());
dto.setPkEndTime(pk.getPkEndTime());
dto.setContributors(pkDetailService.getContributors(pk));
dto.setHistory(pkDetailService.getHistory(pk));
return dto;
}
private int normalizeLimit(Integer limit) {
if (limit == null) {
return PkWxQueryConstants.CLERK_SCHEDULE_DEFAULT_LIMIT;
}
if (limit < PkWxQueryConstants.CLERK_SCHEDULE_MIN_LIMIT) {
return PkWxQueryConstants.CLERK_SCHEDULE_MIN_LIMIT;
}
if (limit > PkWxQueryConstants.CLERK_SCHEDULE_MAX_LIMIT) {
return PkWxQueryConstants.CLERK_SCHEDULE_MAX_LIMIT;
}
return limit;
}
private String resolveState(String status) {
if (ClerkPkEnum.IN_PROGRESS.name().equals(status)) {
return PkWxState.ACTIVE.getValue();
}
if (ClerkPkEnum.TO_BE_STARTED.name().equals(status)) {
return PkWxState.UPCOMING.getValue();
}
return PkWxState.INACTIVE.getValue();
}
private void fillBase(WxPkLiveDto dto, PlayClerkPkEntity pk) {
dto.setId(pk.getId());
dto.setClerkAId(pk.getClerkA());
dto.setClerkBId(pk.getClerkB());
fillClerkInfo(dto);
}
private void fillBase(WxPkUpcomingDto dto, PlayClerkPkEntity pk) {
dto.setId(pk.getId());
dto.setClerkAId(pk.getClerkA());
dto.setClerkBId(pk.getClerkB());
fillClerkInfo(dto);
}
private void fillBase(WxPkDetailDto dto, PlayClerkPkEntity pk) {
dto.setId(pk.getId());
dto.setClerkAId(pk.getClerkA());
dto.setClerkBId(pk.getClerkB());
fillClerkInfo(dto);
}
private void fillClerkInfo(WxPkLiveDto dto) {
fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto);
}
private void fillClerkInfo(WxPkUpcomingDto dto) {
fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto);
}
private void fillClerkInfo(WxPkDetailDto dto) {
fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto);
}
private void fillClerkInfo(String clerkAId, String clerkBId, Object target) {
PlayClerkUserInfoEntity clerkA = clerkUserInfoService.getById(clerkAId);
PlayClerkUserInfoEntity clerkB = clerkUserInfoService.getById(clerkBId);
if (target instanceof WxPkLiveDto) {
WxPkLiveDto dto = (WxPkLiveDto) target;
dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname());
dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname());
dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar());
dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar());
return;
}
if (target instanceof WxPkUpcomingDto) {
WxPkUpcomingDto dto = (WxPkUpcomingDto) target;
dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname());
dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname());
dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar());
dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar());
return;
}
if (target instanceof WxPkDetailDto) {
WxPkDetailDto dto = (WxPkDetailDto) target;
dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname());
dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname());
dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar());
dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar());
}
}
}

View File

@@ -34,6 +34,16 @@ public class PlayClerkOrderDetailsReturnVo {
*/ */
private String placeType; private String placeType;
/**
* 订单关系类型FIRST/CONTINUED/NEUTRAL
*/
private com.starry.admin.modules.order.module.constant.OrderConstant.OrderRelationType orderRelationType;
/**
* 是否是首单【0不是1是】
*/
private String firstOrder;
/** /**
* 商品数量 * 商品数量
*/ */

View File

@@ -34,6 +34,11 @@ public class PlayClerkOrderListReturnVo {
*/ */
private String placeType; private String placeType;
/**
* 订单关系类型FIRST/CONTINUED/NEUTRAL
*/
private com.starry.admin.modules.order.module.constant.OrderConstant.OrderRelationType orderRelationType;
/** /**
* 是否是首单【0不是1是】 * 是否是首单【0不是1是】
*/ */

View File

@@ -35,6 +35,16 @@ public class PlayCustomOrderDetailsReturnVo {
*/ */
private String placeType; private String placeType;
/**
* 订单关系类型FIRST/CONTINUED/NEUTRAL
*/
private com.starry.admin.modules.order.module.constant.OrderConstant.OrderRelationType orderRelationType;
/**
* 是否是首单【0不是1是】
*/
private String firstOrder;
/** /**
* 商品数量 * 商品数量
*/ */

View File

@@ -35,6 +35,16 @@ public class PlayCustomOrderListReturnVo {
*/ */
private String placeType; private String placeType;
/**
* 订单关系类型FIRST/CONTINUED/NEUTRAL
*/
private com.starry.admin.modules.order.module.constant.OrderConstant.OrderRelationType orderRelationType;
/**
* 是否是首单【0不是1是】
*/
private String firstOrder;
/** /**
* 商品数量 * 商品数量
*/ */

View File

@@ -32,6 +32,11 @@ public class PlayRandomOrderInfoReturnVo {
*/ */
private String placeType; private String placeType;
/**
* 订单关系类型FIRST/CONTINUED/NEUTRAL
*/
private com.starry.admin.modules.order.module.constant.OrderConstant.OrderRelationType orderRelationType;
/** /**
* 是否是首单【0不是1是】 * 是否是首单【0不是1是】
*/ */

View File

@@ -179,7 +179,7 @@ public class WxBlindBoxOrderService {
.orderType(OrderConstant.OrderType.BLIND_BOX_PURCHASE) .orderType(OrderConstant.OrderType.BLIND_BOX_PURCHASE)
.placeType(OrderConstant.PlaceType.REWARD) .placeType(OrderConstant.PlaceType.REWARD)
.rewardType(RewardType.GIFT) .rewardType(RewardType.GIFT)
.isFirstOrder(false) .orderRelationType(OrderConstant.OrderRelationType.UNASSIGNED)
.creatorActor(OrderActor.CUSTOMER) .creatorActor(OrderActor.CUSTOMER)
.creatorId(customer.getId()) .creatorId(customer.getId())
.commodityInfo(CommodityInfo.builder() .commodityInfo(CommodityInfo.builder()

View File

@@ -52,6 +52,9 @@ public class WxCustomMpService {
@Resource @Resource
private WxMpService wxMpService; private WxMpService wxMpService;
@Value("${wechat.subscribe-check-enabled:true}")
private boolean subscribeCheckEnabled;
@Resource @Resource
private SysTenantServiceImpl tenantService; private SysTenantServiceImpl tenantService;
@Resource @Resource
@@ -480,6 +483,9 @@ public class WxCustomMpService {
if (StrUtil.isBlankIfStr(openId)) { if (StrUtil.isBlankIfStr(openId)) {
throw new ServiceException("openId不能为空"); throw new ServiceException("openId不能为空");
} }
if (!subscribeCheckEnabled) {
return;
}
try { try {
WxMpUser wxMpUser = proxyWxMpService(tenantId).getUserService().userInfo(openId); WxMpUser wxMpUser = proxyWxMpService(tenantId).getUserService().userInfo(openId);
if (!wxMpUser.getSubscribe()) { if (!wxMpUser.getSubscribe()) {

View File

@@ -108,7 +108,7 @@ public class WxGiftOrderService {
.orderType(OrderConstant.OrderType.NORMAL) .orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.REWARD) .placeType(OrderConstant.PlaceType.REWARD)
.rewardType(RewardType.GIFT) .rewardType(RewardType.GIFT)
.isFirstOrder(true) .orderRelationType(OrderConstant.OrderRelationType.UNASSIGNED)
.creatorActor(OrderActor.CUSTOMER) .creatorActor(OrderActor.CUSTOMER)
.creatorId(purchaserId) .creatorId(purchaserId)
.commodityInfo(CommodityInfo.builder() .commodityInfo(CommodityInfo.builder()

View File

@@ -0,0 +1,128 @@
package com.starry.admin.modules.withdraw.controller;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.TypedR;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import javax.annotation.Resource;
import lombok.Data;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin/earnings/adjustments")
public class AdminEarningsAdjustmentController {
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
@Resource
private IEarningsAdjustmentService adjustmentService;
@Data
public static class CreateAdjustmentRequest {
private String clerkId;
private BigDecimal amount;
private EarningsAdjustmentReasonType reasonType;
private String reasonDescription;
private LocalDateTime effectiveTime;
}
@Data
public static class AdjustmentStatusResponse {
private String adjustmentId;
private String idempotencyKey;
private String status;
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("@customSs.hasPermission('withdraw:adjustment:create') and @earningsAuth.canManageClerk(#body.clerkId)")
public ResponseEntity<TypedR<AdjustmentStatusResponse>> create(
@RequestHeader(value = IDEMPOTENCY_HEADER, required = false) String idempotencyKey,
@RequestBody CreateAdjustmentRequest body) {
if (!StringUtils.hasText(idempotencyKey)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "Idempotency-Key required"));
}
if (body == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
}
if (!StringUtils.hasText(body.getClerkId())) {
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkId required"));
}
if (body.getAmount() == null || body.getAmount().compareTo(BigDecimal.ZERO) == 0) {
return ResponseEntity.badRequest().body(TypedR.error(400, "amount must be non-zero"));
}
if (body.getReasonType() == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonType required"));
}
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
}
String tenantId = SecurityUtils.getTenantId();
if (!StringUtils.hasText(tenantId)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "tenant missing"));
}
EarningsLineAdjustmentEntity adjustment;
try {
adjustment = adjustmentService.createOrGetProcessing(
tenantId,
body.getClerkId(),
body.getAmount(),
body.getReasonType(),
body.getReasonDescription(),
idempotencyKey,
body.getEffectiveTime());
} catch (IllegalStateException conflict) {
return ResponseEntity.status(409).body(TypedR.error(409, conflict.getMessage()));
} catch (CustomException bad) {
return ResponseEntity.badRequest().body(TypedR.error(400, bad.getMessage()));
}
adjustmentService.triggerApplyAsync(adjustment.getId());
AdjustmentStatusResponse responseBody = new AdjustmentStatusResponse();
responseBody.setAdjustmentId(adjustment.getId());
responseBody.setIdempotencyKey(idempotencyKey);
responseBody.setStatus(adjustment.getStatus() == null ? "PROCESSING" : adjustment.getStatus().name());
TypedR<AdjustmentStatusResponse> result = TypedR.ok(responseBody);
result.setCode(202);
result.setMessage("请求处理中");
return ResponseEntity.accepted()
.header("Location", "/admin/earnings/adjustments/idempotency/" + idempotencyKey)
.body(result);
}
@GetMapping("/idempotency/{key}")
@PreAuthorize("@customSs.hasPermission('withdraw:adjustment:read') and @earningsAuth.canReadAdjustmentByIdempotencyKey(#key)")
public ResponseEntity<TypedR<AdjustmentStatusResponse>> getByIdempotencyKey(@PathVariable("key") String key) {
if (!StringUtils.hasText(key)) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
String tenantId = SecurityUtils.getTenantId();
EarningsLineAdjustmentEntity adjustment = adjustmentService.getByIdempotencyKey(tenantId, key);
if (adjustment == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
AdjustmentStatusResponse responseBody = new AdjustmentStatusResponse();
responseBody.setAdjustmentId(adjustment.getId());
responseBody.setIdempotencyKey(key);
responseBody.setStatus(adjustment.getStatus() == null ? "PROCESSING" : adjustment.getStatus().name());
return ResponseEntity.ok(TypedR.ok(responseBody));
}
}

View File

@@ -0,0 +1,384 @@
package com.starry.admin.modules.withdraw.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
import com.starry.admin.modules.withdraw.security.EarningsAuthorizationService;
import com.starry.admin.modules.withdraw.service.IEarningsDeductionBatchService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.TypedR;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Resource;
import lombok.Data;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin/earnings/deductions")
public class AdminEarningsDeductionBatchController {
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
private static final DateTimeFormatter SIMPLE_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Resource
private IEarningsDeductionBatchService batchService;
@Resource
private EarningsDeductionItemMapper deductionItemMapper;
@Resource
private EarningsAuthorizationService earningsAuth;
@Data
public static class DeductionRequest {
private List<String> clerkIds;
private String beginTime;
private String endTime;
private EarningsDeductionRuleType ruleType;
private BigDecimal amount;
private BigDecimal percentage;
private EarningsDeductionOperationType operation;
private String reasonDescription;
}
@Data
public static class PreviewItem {
private String clerkId;
private BigDecimal baseAmount;
private BigDecimal applyAmount;
}
@Data
public static class PreviewResponse {
private List<PreviewItem> items;
}
@Data
public static class BatchStatusResponse {
private String batchId;
private String idempotencyKey;
private String status;
}
@PostMapping(value = "/preview", consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
public ResponseEntity<TypedR<PreviewResponse>> preview(@RequestBody DeductionRequest body) {
if (body == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
}
List<String> clerkIds = normalizeClerkIds(body.getClerkIds());
if (clerkIds.isEmpty()) {
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkIds required"));
}
LocalDateTime begin = parseDate(body.getBeginTime());
LocalDateTime end = parseDate(body.getEndTime());
if (begin == null || end == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "time range required"));
}
if (begin.isAfter(end)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "beginTime must be <= endTime"));
}
EarningsDeductionRuleType ruleType = body.getRuleType();
if (ruleType == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleType required"));
}
EarningsDeductionOperationType operation = body.getOperation();
if (operation == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "operation required"));
}
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
}
BigDecimal ruleValue = resolveRuleValue(ruleType, body.getAmount(), body.getPercentage());
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleValue must be non-zero"));
}
enforceScope(clerkIds);
String tenantId = SecurityUtils.getTenantId();
PreviewResponse response = new PreviewResponse();
List<PreviewItem> items = new ArrayList<>();
for (String clerkId : clerkIds) {
BigDecimal base = deductionItemMapper.sumOrderPositiveBase(tenantId, clerkId, begin, end);
BigDecimal baseAmount = base == null ? BigDecimal.ZERO : base;
BigDecimal applyAmount = calculateApplyAmount(ruleType, ruleValue, operation, baseAmount);
PreviewItem item = new PreviewItem();
item.setClerkId(clerkId);
item.setBaseAmount(baseAmount.setScale(2, RoundingMode.HALF_UP));
item.setApplyAmount(applyAmount.setScale(2, RoundingMode.HALF_UP));
items.add(item);
}
response.setItems(items);
return ResponseEntity.ok(TypedR.ok(response));
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
public ResponseEntity<TypedR<BatchStatusResponse>> create(
@RequestHeader(value = IDEMPOTENCY_HEADER, required = false) String idempotencyKey,
@RequestBody DeductionRequest body) {
if (!StringUtils.hasText(idempotencyKey)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "Idempotency-Key required"));
}
if (body == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
}
List<String> clerkIds = normalizeClerkIds(body.getClerkIds());
if (clerkIds.isEmpty()) {
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkIds required"));
}
LocalDateTime begin = parseDate(body.getBeginTime());
LocalDateTime end = parseDate(body.getEndTime());
if (begin == null || end == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "time range required"));
}
if (begin.isAfter(end)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "beginTime must be <= endTime"));
}
if (body.getRuleType() == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleType required"));
}
if (body.getOperation() == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "operation required"));
}
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
}
BigDecimal ruleValue = resolveRuleValue(body.getRuleType(), body.getAmount(), body.getPercentage());
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleValue must be non-zero"));
}
enforceScope(clerkIds);
String tenantId = SecurityUtils.getTenantId();
if (!StringUtils.hasText(tenantId)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "tenant missing"));
}
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch;
try {
batch = batchService.createOrGetProcessing(
tenantId,
clerkIds,
begin,
end,
body.getRuleType(),
ruleValue,
body.getOperation(),
body.getReasonDescription(),
idempotencyKey);
} catch (IllegalStateException conflict) {
return ResponseEntity.status(409).body(TypedR.error(409, conflict.getMessage()));
} catch (CustomException bad) {
return ResponseEntity.badRequest().body(TypedR.error(400, bad.getMessage()));
}
batchService.triggerApplyAsync(batch.getId());
BatchStatusResponse responseBody = new BatchStatusResponse();
responseBody.setBatchId(batch.getId());
responseBody.setIdempotencyKey(idempotencyKey);
responseBody.setStatus(batch.getStatus() == null ? "PROCESSING" : batch.getStatus().name());
TypedR<BatchStatusResponse> result = TypedR.ok(responseBody);
result.setCode(202);
result.setMessage("请求处理中");
return ResponseEntity.accepted()
.header("Location", "/admin/earnings/deductions/idempotency/" + idempotencyKey)
.body(result);
}
@GetMapping("/idempotency/{key}")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
public ResponseEntity<TypedR<BatchStatusResponse>> getByIdempotencyKey(@PathVariable("key") String key) {
if (!StringUtils.hasText(key)) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.getByIdempotencyKey(tenantId, key);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batch.getId());
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity reconciled = batchService.reconcileAndGet(tenantId, batch.getId());
if (reconciled == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
BatchStatusResponse responseBody = new BatchStatusResponse();
responseBody.setBatchId(reconciled.getId());
responseBody.setIdempotencyKey(key);
responseBody.setStatus(reconciled.getStatus() == null ? "PROCESSING" : reconciled.getStatus().name());
return ResponseEntity.ok(TypedR.ok(responseBody));
}
@GetMapping("/{batchId}")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
public ResponseEntity<TypedR<BatchStatusResponse>> getById(@PathVariable("batchId") String batchId) {
if (!StringUtils.hasText(batchId)) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batch.getId());
BatchStatusResponse responseBody = new BatchStatusResponse();
responseBody.setBatchId(batch.getId());
responseBody.setIdempotencyKey(batch.getIdempotencyKey());
responseBody.setStatus(batch.getStatus() == null ? "PROCESSING" : batch.getStatus().name());
return ResponseEntity.ok(TypedR.ok(responseBody));
}
@GetMapping("/{batchId}/items")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
public ResponseEntity<TypedR<List<EarningsDeductionItemEntity>>> listItems(
@PathVariable("batchId") String batchId,
@RequestParam(value = "pageNum", required = false, defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batchId);
IPage<EarningsDeductionItemEntity> page = batchService.pageItems(tenantId, batchId, pageNum, pageSize);
return ResponseEntity.ok(TypedR.okPage(page));
}
@GetMapping("/{batchId}/logs")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
public ResponseEntity<TypedR<List<EarningsDeductionBatchLogEntity>>> listLogs(@PathVariable("batchId") String batchId) {
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batchId);
List<EarningsDeductionBatchLogEntity> logs = batchService.listLogs(tenantId, batchId);
return ResponseEntity.ok(TypedR.ok(logs));
}
@PostMapping("/{batchId}/retry")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
public ResponseEntity<TypedR<Void>> retry(@PathVariable("batchId") String batchId) {
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batchId);
batchService.retryAsync(batchId);
return ResponseEntity.ok(TypedR.ok(null));
}
private void enforceScope(List<String> clerkIds) {
for (String clerkId : clerkIds) {
if (!earningsAuth.canManageClerk(clerkId)) {
throw new AccessDeniedException("forbidden");
}
}
}
private void enforceBatchScope(String tenantId, String batchId) {
if (SecurityUtils.getLoginUser() != null
&& SecurityUtils.getLoginUser().getUser() != null
&& SecurityUtils.isAdmin(SecurityUtils.getLoginUser().getUser())) {
return;
}
List<EarningsDeductionItemEntity> items = batchService.listItems(tenantId, batchId);
for (EarningsDeductionItemEntity item : items) {
if (item != null && StringUtils.hasText(item.getClerkId())) {
if (!earningsAuth.canManageClerk(item.getClerkId())) {
throw new AccessDeniedException("forbidden");
}
}
}
}
private LocalDateTime parseDate(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
try {
return LocalDateTime.parse(value.trim(), SIMPLE_DATE_TIME);
} catch (DateTimeParseException ex) {
throw new CustomException("invalid datetime: " + value);
}
}
private BigDecimal resolveRuleValue(EarningsDeductionRuleType type, BigDecimal amount, BigDecimal percentage) {
if (type == EarningsDeductionRuleType.FIXED) {
return amount;
}
return percentage;
}
private BigDecimal calculateApplyAmount(EarningsDeductionRuleType ruleType,
BigDecimal ruleValue,
EarningsDeductionOperationType operation,
BigDecimal baseAmount) {
BigDecimal resolvedBase = baseAmount == null ? BigDecimal.ZERO : baseAmount;
BigDecimal resolvedRule = ruleValue == null ? BigDecimal.ZERO : ruleValue;
BigDecimal amount;
if (ruleType == EarningsDeductionRuleType.FIXED) {
amount = resolvedRule;
} else {
amount = resolvedBase.multiply(resolvedRule).divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
}
amount = amount.setScale(2, RoundingMode.HALF_UP);
if (operation == EarningsDeductionOperationType.PUNISHMENT) {
return amount.abs().negate();
}
return amount.abs();
}
private List<String> normalizeClerkIds(List<String> input) {
List<String> result = new ArrayList<>();
if (CollectionUtils.isEmpty(input)) {
return result;
}
for (String id : input) {
if (!StringUtils.hasText(id)) {
continue;
}
String trimmed = id.trim();
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
result.add(trimmed);
}
}
return result;
}
}

View File

@@ -43,6 +43,9 @@ import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.annotation.Resource; import javax.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@Api(tags = "提现管理-后台") @Api(tags = "提现管理-后台")
@@ -195,6 +198,36 @@ public class AdminWithdrawalController {
return TypedR.ok(vos); return TypedR.ok(vos);
} }
public static class RejectWithdrawalRequest {
private String reason;
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}
@ApiOperation("拒绝/取消提现请求(释放已预留收益)")
@PostMapping("/requests/{id}/reject")
@PreAuthorize("@customSs.hasPermission('withdraw:request:reject') and @earningsAuth.canRejectWithdrawal(#id)")
public ResponseEntity<TypedR<Void>> reject(@PathVariable("id") String id, @RequestBody(required = false) RejectWithdrawalRequest body) {
String tenantId = SecurityUtils.getTenantId();
WithdrawalRequestEntity req = withdrawalService.getById(id);
if (req == null || !tenantId.equals(req.getTenantId())) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(TypedR.error(404, "请求不存在"));
}
String reason = body == null ? null : body.getReason();
try {
withdrawalService.reject(req.getId(), reason);
} catch (CustomException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(TypedR.error(400, ex.getMessage()));
}
return ResponseEntity.ok(TypedR.ok(null));
}
@ApiOperation("分页查询收益明细") @ApiOperation("分页查询收益明细")
@PostMapping("/earnings/listByPage") @PostMapping("/earnings/listByPage")
public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) { public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) {

View File

@@ -8,14 +8,19 @@ import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
import com.starry.admin.modules.withdraw.enums.WithdrawalRequestStatus;
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService; import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService; import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo; import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo;
import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo; import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.TypedR; import com.starry.common.result.TypedR;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -40,6 +45,8 @@ public class WxWithdrawController {
@Resource @Resource
private IEarningsService earningsService; private IEarningsService earningsService;
@Resource @Resource
private IEarningsAdjustmentService adjustmentService;
@Resource
private IWithdrawalService withdrawalService; private IWithdrawalService withdrawalService;
@Resource @Resource
private IWithdrawalLogService withdrawalLogService; private IWithdrawalLogService withdrawalLogService;
@@ -55,11 +62,43 @@ public class WxWithdrawController {
@GetMapping("/balance") @GetMapping("/balance")
public TypedR<ClerkWithdrawBalanceVo> getBalance() { public TypedR<ClerkWithdrawBalanceVo> getBalance() {
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId(); String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
String tenantId = SecurityUtils.getTenantId();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
BigDecimal available = earningsService.getAvailableAmount(clerkId, now); BigDecimal available = earningsService.getAvailableAmount(clerkId, now);
BigDecimal pending = earningsService.getPendingAmount(clerkId, now); BigDecimal pending = earningsService.getPendingAmount(clerkId, now);
LocalDateTime nextUnlock = earningsService.getNextUnlockTime(clerkId, now); LocalDateTime nextUnlock = earningsService.getNextUnlockTime(clerkId, now);
return TypedR.ok(new ClerkWithdrawBalanceVo(available, pending, nextUnlock)); WithdrawalRequestEntity active = withdrawalService.lambdaQuery()
.eq(WithdrawalRequestEntity::getTenantId, tenantId)
.eq(WithdrawalRequestEntity::getClerkId, clerkId)
.in(WithdrawalRequestEntity::getStatus,
WithdrawalRequestStatus.PENDING.getCode(),
WithdrawalRequestStatus.PROCESSING.getCode())
.orderByDesc(WithdrawalRequestEntity::getCreatedTime)
.last("limit 1")
.one();
Boolean locked = active != null;
String lockReason = locked ? "当前已有一笔提现申请在途,请等待处理完成后再申请。" : "";
ClerkWithdrawBalanceVo.ActiveRequest activeRequest = null;
if (active != null) {
Date createdTime = active.getCreatedTime();
LocalDateTime createdAt = createdTime == null ? null
: LocalDateTime.ofInstant(createdTime.toInstant(), ZoneId.systemDefault());
activeRequest = ClerkWithdrawBalanceVo.ActiveRequest.builder()
.amount(active.getAmount())
.status(active.getStatus())
.createdTime(createdAt == null ? "" : createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
.build();
}
ClerkWithdrawBalanceVo vo = new ClerkWithdrawBalanceVo();
vo.setAvailable(available);
vo.setPending(pending);
vo.setNextUnlockAt(nextUnlock);
vo.setWithdrawLocked(locked);
vo.setWithdrawLockReason(lockReason);
vo.setActiveRequest(activeRequest);
return TypedR.ok(vo);
} }
@ClerkUserLogin @ClerkUserLogin
@@ -101,6 +140,21 @@ public class WxWithdrawController {
.list() .list()
.stream() .stream()
.collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it)); .collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it));
List<String> adjustmentIds = records.stream()
.filter(line -> line.getSourceType() == EarningsSourceType.ADJUSTMENT)
.map(EarningsLineEntity::getSourceId)
.filter(id -> id != null && !id.isEmpty())
.distinct()
.collect(Collectors.toList());
Map<String, EarningsLineAdjustmentEntity> adjustmentMap = adjustmentIds.isEmpty() ? java.util.Collections.emptyMap()
: adjustmentService.lambdaQuery()
.eq(EarningsLineAdjustmentEntity::getTenantId, SecurityUtils.getTenantId())
.in(EarningsLineAdjustmentEntity::getId, adjustmentIds)
.list()
.stream()
.collect(Collectors.toMap(EarningsLineAdjustmentEntity::getId, it -> it));
for (EarningsLineEntity line : records) { for (EarningsLineEntity line : records) {
ClerkEarningLineVo vo = new ClerkEarningLineVo(); ClerkEarningLineVo vo = new ClerkEarningLineVo();
vo.setId(line.getId()); vo.setId(line.getId());
@@ -111,6 +165,14 @@ public class WxWithdrawController {
vo.setUnlockTime(line.getUnlockTime()); vo.setUnlockTime(line.getUnlockTime());
vo.setCreatedTime(toLocalDateTime(line.getCreatedTime())); vo.setCreatedTime(toLocalDateTime(line.getCreatedTime()));
vo.setOrderId(line.getOrderId()); vo.setOrderId(line.getOrderId());
if (line.getSourceType() == EarningsSourceType.ADJUSTMENT && line.getSourceId() != null) {
EarningsLineAdjustmentEntity adjustment = adjustmentMap.get(line.getSourceId());
if (adjustment != null) {
vo.setAdjustmentReasonType(adjustment.getReasonType());
vo.setAdjustmentReasonDescription(adjustment.getReasonDescription());
vo.setAdjustmentEffectiveTime(adjustment.getEffectiveTime());
}
}
if (line.getOrderId() != null) { if (line.getOrderId() != null) {
PlayOrderInfoEntity order = orderMap.get(line.getOrderId()); PlayOrderInfoEntity order = orderMap.get(line.getOrderId());
if (order != null) { if (order != null) {

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
import com.starry.common.domain.BaseEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_earnings_deduction_batch")
public class EarningsDeductionBatchEntity extends BaseEntity<EarningsDeductionBatchEntity> {
private String id;
private String tenantId;
private EarningsDeductionStatus status;
@TableField("begin_time")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime windowBeginTime;
@TableField("end_time")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime windowEndTime;
private EarningsDeductionRuleType ruleType;
private BigDecimal ruleValue;
private EarningsDeductionOperationType operation;
private String reasonDescription;
private String idempotencyKey;
private String requestHash;
}

Some files were not shown because too many files have changed in this diff Show More