Compare commits
6 Commits
d335c577d3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b9b1024c8 | ||
|
|
fffc623ab0 | ||
|
|
6a3b4fef1f | ||
|
|
e2300fc7d0 | ||
|
|
985b35cd90 | ||
|
|
56239450d4 |
177
llm/earnings-adjustments-and-withdrawal-reject.md
Normal file
177
llm/earnings-adjustments-and-withdrawal-reject.md
Normal 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 entity’s `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`)
|
||||
|
||||
359
llm/wechat-subsystem-test-matrix.md
Normal file
359
llm/wechat-subsystem-test-matrix.md
Normal 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)
|
||||
@@ -163,6 +163,11 @@
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-inline</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-test</artifactId>
|
||||
|
||||
@@ -5,6 +5,9 @@ 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.PlayClerkLevelInfoEntity;
|
||||
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.IPlayClerkLevelInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
@@ -25,7 +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.IPlayCommodityInfoService;
|
||||
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.SysTenantPackageEntity;
|
||||
import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
||||
@@ -34,16 +39,19 @@ import com.starry.admin.modules.system.service.ISysTenantService;
|
||||
import com.starry.admin.modules.system.service.SysUserService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.UserConstants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -69,9 +77,11 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
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_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_NAME = "API测试礼物";
|
||||
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_STATE_ACTIVE = "0";
|
||||
private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00");
|
||||
@@ -81,6 +91,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
private final ISysTenantService tenantService;
|
||||
private final SysUserService sysUserService;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
private final SysMenuMapper sysMenuMapper;
|
||||
private final IPlayPersonnelGroupInfoService personnelGroupInfoService;
|
||||
private final IPlayClerkLevelInfoService clerkLevelInfoService;
|
||||
private final IPlayClerkUserInfoService clerkUserInfoService;
|
||||
@@ -103,6 +114,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
ISysTenantService tenantService,
|
||||
SysUserService sysUserService,
|
||||
SysUserMapper sysUserMapper,
|
||||
SysMenuMapper sysMenuMapper,
|
||||
IPlayPersonnelGroupInfoService personnelGroupInfoService,
|
||||
IPlayClerkLevelInfoService clerkLevelInfoService,
|
||||
IPlayClerkUserInfoService clerkUserInfoService,
|
||||
@@ -123,6 +135,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
this.tenantService = tenantService;
|
||||
this.sysUserService = sysUserService;
|
||||
this.sysUserMapper = sysUserMapper;
|
||||
this.sysMenuMapper = sysMenuMapper;
|
||||
this.personnelGroupInfoService = personnelGroupInfoService;
|
||||
this.clerkLevelInfoService = clerkLevelInfoService;
|
||||
this.clerkUserInfoService = clerkUserInfoService;
|
||||
@@ -144,6 +157,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
seedPcTenantWagesMenu();
|
||||
seedTenantPackage();
|
||||
seedTenant();
|
||||
|
||||
@@ -168,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() {
|
||||
long existing = tenantPackageService.count(Wrappers.<SysTenantPackageEntity>lambdaQuery()
|
||||
.eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID));
|
||||
@@ -189,6 +295,28 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
private void seedTenant() {
|
||||
SysTenantEntity tenant = tenantService.getById(DEFAULT_TENANT_ID);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -200,6 +328,10 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setTenantStatus("0");
|
||||
entity.setTenantCode("apitest");
|
||||
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.setTenantTime(new Date(System.currentTimeMillis() + 365L * 24 * 3600 * 1000));
|
||||
entity.setUserName(DEFAULT_ADMIN_USERNAME);
|
||||
@@ -250,8 +382,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setGroupName("测试小组");
|
||||
entity.setLeaderName("API Admin");
|
||||
entity.setAddTime(LocalDateTime.now());
|
||||
personnelGroupInfoService.save(entity);
|
||||
log.info("Inserted API test personnel group {}", DEFAULT_GROUP_ID);
|
||||
try {
|
||||
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() {
|
||||
@@ -273,8 +409,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setFirstRewardRatio(40);
|
||||
entity.setNotFirstRewardRatio(35);
|
||||
entity.setOrderNumber(1L);
|
||||
clerkLevelInfoService.save(entity);
|
||||
log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID);
|
||||
try {
|
||||
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() {
|
||||
@@ -405,8 +545,18 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(DEFAULT_CLERK_ID);
|
||||
String clerkToken = wxTokenService.createWxUserToken(DEFAULT_CLERK_ID);
|
||||
if (clerk != null) {
|
||||
clerkUserInfoService.updateTokenById(DEFAULT_CLERK_ID, clerkToken);
|
||||
log.info("API test clerk {} already exists", DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.update(Wrappers.<PlayClerkUserInfoEntity>lambdaUpdate()
|
||||
.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);
|
||||
@@ -447,26 +597,38 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
|
||||
private void seedClerkCommodity() {
|
||||
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;
|
||||
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
|
||||
if (parent != null && parent.getItemName() != null) {
|
||||
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();
|
||||
entity.setId(DEFAULT_CLERK_COMMODITY_ID);
|
||||
entity.setTenantId(DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(DEFAULT_CLERK_ID);
|
||||
entity.setCommodityId(DEFAULT_COMMODITY_ID);
|
||||
entity.setCommodityId(DEFAULT_COMMODITY_PARENT_ID);
|
||||
entity.setCommodityName(commodityName);
|
||||
entity.setEnablingState("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);
|
||||
}
|
||||
|
||||
@@ -489,7 +651,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setState(GIFT_STATE_ACTIVE);
|
||||
entity.setListingTime(LocalDateTime.now());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -548,7 +715,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
|
||||
entity.setId(DEFAULT_CUSTOMER_ID);
|
||||
entity.setTenantId(DEFAULT_TENANT_ID);
|
||||
entity.setOpenid("openid-customer-apitest");
|
||||
entity.setOpenid(DEFAULT_CUSTOMER_OPEN_ID);
|
||||
entity.setUnionid("unionid-customer-apitest");
|
||||
entity.setNickname("测试顾客");
|
||||
entity.setSex(1);
|
||||
@@ -564,7 +731,24 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setRegistrationTime(new Date());
|
||||
entity.setLastLoginTime(new Date());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ public class PermissionService {
|
||||
}
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
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);
|
||||
}
|
||||
@@ -70,7 +73,13 @@ public class PermissionService {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Set<String> authorities = loginUser.getPermissions();
|
||||
|
||||
@@ -8,7 +8,11 @@ import com.starry.common.utils.StringUtils;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.FieldError;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
@@ -26,6 +30,16 @@ public class GlobalExceptionHandler {
|
||||
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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务异常
|
||||
*/
|
||||
|
||||
@@ -6,10 +6,14 @@ import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
||||
import com.starry.common.constant.SecurityConstants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -24,6 +28,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
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) {
|
||||
this.properties = properties;
|
||||
@@ -32,6 +38,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
Map<String, Object> originalContext = new ConcurrentHashMap<>(CustomSecurityContextHolder.getLocalMap());
|
||||
String requestedUser = request.getHeader(properties.getUserHeader());
|
||||
String requestedTenant = request.getHeader(properties.getTenantHeader());
|
||||
|
||||
@@ -48,6 +55,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
try {
|
||||
LoginUser loginUser = buildLoginUser(userId, tenantId);
|
||||
applyOverridesFromHeaders(request, loginUser);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null,
|
||||
Collections.emptyList());
|
||||
@@ -61,7 +69,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
CustomSecurityContextHolder.setLocalMap(originalContext);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
@@ -93,4 +101,27 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ public class ClerkPerformanceOverviewQueryVo extends PlayClerkPerformanceInfoQue
|
||||
@ApiModelProperty(value = "是否包含排行列表数据")
|
||||
private Boolean includeRankings = Boolean.TRUE;
|
||||
|
||||
@ApiModelProperty(value = "是否包含收益调整(ADJUSTMENT)", allowableValues = "true,false")
|
||||
private Boolean includeAdjustments = Boolean.FALSE;
|
||||
|
||||
@Override
|
||||
public void setEndOrderTime(List<String> endOrderTime) {
|
||||
super.setEndOrderTime(endOrderTime);
|
||||
|
||||
@@ -142,6 +142,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) {
|
||||
DateRange range = resolveDateRange(vo.getEndOrderTime());
|
||||
List<PlayClerkUserInfoEntity> clerks = loadAccessibleClerks(vo);
|
||||
boolean includeAdjustments = Boolean.TRUE.equals(vo.getIncludeAdjustments());
|
||||
ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo();
|
||||
if (CollectionUtil.isEmpty(clerks)) {
|
||||
responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo());
|
||||
@@ -158,7 +159,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
for (PlayClerkUserInfoEntity clerk : clerks) {
|
||||
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
|
||||
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();
|
||||
ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots);
|
||||
@@ -194,7 +195,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
|
||||
range.startTime, range.endTime);
|
||||
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();
|
||||
responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap));
|
||||
responseVo.setSnapshot(snapshot);
|
||||
@@ -424,22 +425,42 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
|
||||
private BigDecimal calculateEarningsAmount(String clerkId, List<PlayOrderInfoEntity> orders, String startTime,
|
||||
String endTime) {
|
||||
if (StrUtil.isBlank(clerkId) || CollectionUtil.isEmpty(orders)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
List<String> orderIds = orders.stream()
|
||||
.filter(this::isCompletedOrder)
|
||||
.map(PlayOrderInfoEntity::getId)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.collect(Collectors.toList());
|
||||
if (CollectionUtil.isEmpty(orderIds)) {
|
||||
return calculateEarningsAmount(SecurityUtils.getTenantId(), clerkId, orders, startTime, endTime, false);
|
||||
}
|
||||
|
||||
private BigDecimal calculateEarningsAmount(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
List<PlayOrderInfoEntity> orders,
|
||||
String startTime,
|
||||
String endTime,
|
||||
boolean includeAdjustments) {
|
||||
if (StrUtil.isBlank(clerkId)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime);
|
||||
String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime);
|
||||
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
|
||||
normalizedEnd);
|
||||
return defaultZero(sum);
|
||||
|
||||
BigDecimal orderSum = BigDecimal.ZERO;
|
||||
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) {
|
||||
@@ -460,7 +481,8 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
}
|
||||
|
||||
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();
|
||||
snapshot.setClerkId(clerk.getId());
|
||||
snapshot.setClerkNickname(clerk.getNickname());
|
||||
@@ -513,7 +535,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
}
|
||||
int userCount = userIds.size();
|
||||
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.setFirstOrderAmount(firstAmount);
|
||||
snapshot.setContinuedOrderAmount(continuedAmount);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,9 @@ public class WxCustomMpService {
|
||||
@Resource
|
||||
private WxMpService wxMpService;
|
||||
|
||||
@Value("${wechat.subscribe-check-enabled:true}")
|
||||
private boolean subscribeCheckEnabled;
|
||||
|
||||
@Resource
|
||||
private SysTenantServiceImpl tenantService;
|
||||
@Resource
|
||||
@@ -480,6 +483,9 @@ public class WxCustomMpService {
|
||||
if (StrUtil.isBlankIfStr(openId)) {
|
||||
throw new ServiceException("openId不能为空");
|
||||
}
|
||||
if (!subscribeCheckEnabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
WxMpUser wxMpUser = proxyWxMpService(tenantId).getUserService().userInfo(openId);
|
||||
if (!wxMpUser.getSubscribe()) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
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.*;
|
||||
|
||||
@Api(tags = "提现管理-后台")
|
||||
@@ -195,6 +198,36 @@ public class AdminWithdrawalController {
|
||||
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("分页查询收益明细")
|
||||
@PostMapping("/earnings/listByPage")
|
||||
public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) {
|
||||
|
||||
@@ -8,14 +8,19 @@ import com.starry.admin.common.conf.ThreadLocalRequestDetail;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
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.WithdrawalLogEntity;
|
||||
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.IWithdrawalLogService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo;
|
||||
import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.result.TypedR;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -40,6 +45,8 @@ public class WxWithdrawController {
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
@Resource
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
@Resource
|
||||
private IWithdrawalService withdrawalService;
|
||||
@Resource
|
||||
private IWithdrawalLogService withdrawalLogService;
|
||||
@@ -55,11 +62,43 @@ public class WxWithdrawController {
|
||||
@GetMapping("/balance")
|
||||
public TypedR<ClerkWithdrawBalanceVo> getBalance() {
|
||||
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
BigDecimal available = earningsService.getAvailableAmount(clerkId, now);
|
||||
BigDecimal pending = earningsService.getPendingAmount(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
|
||||
@@ -101,6 +140,21 @@ public class WxWithdrawController {
|
||||
.list()
|
||||
.stream()
|
||||
.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) {
|
||||
ClerkEarningLineVo vo = new ClerkEarningLineVo();
|
||||
vo.setId(line.getId());
|
||||
@@ -111,6 +165,14 @@ public class WxWithdrawController {
|
||||
vo.setUnlockTime(line.getUnlockTime());
|
||||
vo.setCreatedTime(toLocalDateTime(line.getCreatedTime()));
|
||||
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) {
|
||||
PlayOrderInfoEntity order = orderMap.get(line.getOrderId());
|
||||
if (order != null) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionLogEventType;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_deduction_batch_log")
|
||||
public class EarningsDeductionBatchLogEntity extends BaseEntity<EarningsDeductionBatchLogEntity> {
|
||||
private String id;
|
||||
private String batchId;
|
||||
private String tenantId;
|
||||
private EarningsDeductionLogEventType eventType;
|
||||
private String statusFrom;
|
||||
private String statusTo;
|
||||
private String message;
|
||||
private String payload;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_deduction_item")
|
||||
public class EarningsDeductionItemEntity extends BaseEntity<EarningsDeductionItemEntity> {
|
||||
private String id;
|
||||
private String batchId;
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private BigDecimal baseAmount;
|
||||
private BigDecimal applyAmount;
|
||||
private EarningsDeductionStatus status;
|
||||
private String adjustmentId;
|
||||
private String failureReason;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus;
|
||||
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_line_adjustment")
|
||||
public class EarningsLineAdjustmentEntity extends BaseEntity<EarningsLineAdjustmentEntity> {
|
||||
private String id;
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private BigDecimal amount;
|
||||
private EarningsAdjustmentReasonType reasonType;
|
||||
private String reasonDescription;
|
||||
private EarningsAdjustmentStatus status;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime effectiveTime;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime appliedTime;
|
||||
|
||||
private String failureReason;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
@@ -18,6 +19,15 @@ public class EarningsLineEntity extends BaseEntity<EarningsLineEntity> {
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private String orderId;
|
||||
/**
|
||||
* Source identity for ledger line.
|
||||
*
|
||||
* <p>ORDER: sourceId == orderId (non-null).
|
||||
* <p>ADJUSTMENT: sourceId == adjustmentId, and orderId should be null.
|
||||
*/
|
||||
private EarningsSourceType sourceType;
|
||||
|
||||
private String sourceId;
|
||||
private BigDecimal amount;
|
||||
private EarningsType earningType;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Reason type for adjustments (hard-coded for now).
|
||||
*/
|
||||
public enum EarningsAdjustmentReasonType {
|
||||
MANUAL("MANUAL");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsAdjustmentReasonType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsAdjustmentStatus {
|
||||
PROCESSING("PROCESSING"),
|
||||
APPLIED("APPLIED"),
|
||||
FAILED("FAILED");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsAdjustmentStatus(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsDeductionLogEventType {
|
||||
CREATED("CREATED"),
|
||||
APPLY_STARTED("APPLY_STARTED"),
|
||||
ITEM_APPLIED("ITEM_APPLIED"),
|
||||
ITEM_FAILED("ITEM_FAILED"),
|
||||
BATCH_APPLIED("BATCH_APPLIED"),
|
||||
BATCH_FAILED("BATCH_FAILED"),
|
||||
RETRY_STARTED("RETRY_STARTED");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsDeductionLogEventType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsDeductionOperationType {
|
||||
BONUS("BONUS"),
|
||||
PUNISHMENT("PUNISHMENT");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsDeductionOperationType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsDeductionRuleType {
|
||||
FIXED("FIXED"),
|
||||
PERCENTAGE("PERCENTAGE");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsDeductionRuleType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsDeductionStatus {
|
||||
PROCESSING("PROCESSING"),
|
||||
APPLIED("APPLIED"),
|
||||
FAILED("FAILED");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsDeductionStatus(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public enum EarningsLineStatus {
|
||||
FROZEN("frozen"),
|
||||
AVAILABLE("available"),
|
||||
WITHDRAWING("withdrawing"),
|
||||
WITHDRAWN("withdrawn"),
|
||||
REVERSED("reversed");
|
||||
|
||||
private final String code;
|
||||
|
||||
EarningsLineStatus(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static EarningsLineStatus fromCode(String code) {
|
||||
return Arrays.stream(values())
|
||||
.filter(it -> it.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("invalid earnings line status: " + code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Earnings line source type.
|
||||
*/
|
||||
public enum EarningsSourceType {
|
||||
ORDER("ORDER"),
|
||||
ADJUSTMENT("ADJUSTMENT");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsSourceType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public enum WithdrawalRequestStatus {
|
||||
PENDING("pending"),
|
||||
PROCESSING("processing"),
|
||||
SUCCESS("success"),
|
||||
FAILED("failed"),
|
||||
CANCELED("canceled"),
|
||||
REJECTED("rejected");
|
||||
|
||||
private final String code;
|
||||
|
||||
WithdrawalRequestStatus(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static WithdrawalRequestStatus fromCode(String code) {
|
||||
return Arrays.stream(values())
|
||||
.filter(it -> it.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("invalid withdrawal request status: " + code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionBatchLogMapper extends BaseMapper<EarningsDeductionBatchLogEntity> {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionBatchMapper extends BaseMapper<EarningsDeductionBatchEntity> {}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionItemMapper extends BaseMapper<EarningsDeductionItemEntity> {
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT COALESCE(SUM(el.amount), 0) " +
|
||||
"FROM play_earnings_line el " +
|
||||
"JOIN play_order_info oi ON oi.id = el.order_id " +
|
||||
"WHERE el.deleted = 0 " +
|
||||
" AND oi.deleted = 0 " +
|
||||
" AND el.tenant_id = #{tenantId} " +
|
||||
" AND el.clerk_id = #{clerkId} " +
|
||||
" AND el.order_id IS NOT NULL " +
|
||||
" AND el.amount > 0 " +
|
||||
" AND el.status <> 'reversed' " +
|
||||
" AND oi.order_end_time IS NOT NULL " +
|
||||
" AND oi.order_end_time >= #{begin} " +
|
||||
" AND oi.order_end_time <= #{end} " +
|
||||
"</script>")
|
||||
BigDecimal sumOrderPositiveBase(@Param("tenantId") String tenantId,
|
||||
@Param("clerkId") String clerkId,
|
||||
@Param("begin") LocalDateTime begin,
|
||||
@Param("end") LocalDateTime end);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsLineAdjustmentMapper extends BaseMapper<EarningsLineAdjustmentEntity> {}
|
||||
@@ -59,4 +59,24 @@ public interface EarningsLineMapper extends BaseMapper<EarningsLineEntity> {
|
||||
@Param("orderIds") Collection<String> orderIds,
|
||||
@Param("startTime") String startTime,
|
||||
@Param("endTime") String endTime);
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT COALESCE(SUM(amount), 0) " +
|
||||
"FROM play_earnings_line " +
|
||||
"WHERE deleted = 0 " +
|
||||
" AND tenant_id = #{tenantId} " +
|
||||
" AND clerk_id = #{clerkId} " +
|
||||
" AND source_type = 'ADJUSTMENT' " +
|
||||
" AND source_id IS NOT NULL " +
|
||||
"<if test='startTime != null'>" +
|
||||
" AND unlock_time >= #{startTime}" +
|
||||
"</if>" +
|
||||
"<if test='endTime != null'>" +
|
||||
" AND unlock_time <= #{endTime}" +
|
||||
"</if>" +
|
||||
"</script>")
|
||||
BigDecimal sumAdjustmentsByClerk(@Param("tenantId") String tenantId,
|
||||
@Param("clerkId") String clerkId,
|
||||
@Param("startTime") String startTime,
|
||||
@Param("endTime") String endTime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.starry.admin.modules.withdraw.security;
|
||||
|
||||
import com.starry.admin.common.domain.LoginUser;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service("earningsAuth")
|
||||
public class EarningsAuthorizationService {
|
||||
|
||||
private final IPlayClerkUserInfoService clerkUserInfoService;
|
||||
private final IPlayPersonnelGroupInfoService groupInfoService;
|
||||
private final IEarningsAdjustmentService earningsAdjustmentService;
|
||||
private final IWithdrawalService withdrawalService;
|
||||
|
||||
public EarningsAuthorizationService(
|
||||
IPlayClerkUserInfoService clerkUserInfoService,
|
||||
IPlayPersonnelGroupInfoService groupInfoService,
|
||||
IEarningsAdjustmentService earningsAdjustmentService,
|
||||
IWithdrawalService withdrawalService) {
|
||||
this.clerkUserInfoService = clerkUserInfoService;
|
||||
this.groupInfoService = groupInfoService;
|
||||
this.earningsAdjustmentService = earningsAdjustmentService;
|
||||
this.withdrawalService = withdrawalService;
|
||||
}
|
||||
|
||||
public boolean canManageClerk(String clerkId) {
|
||||
if (!StringUtils.hasText(clerkId)) {
|
||||
return false;
|
||||
}
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser == null || loginUser.getUser() == null) {
|
||||
return false;
|
||||
}
|
||||
if (SecurityUtils.isAdmin(loginUser.getUser())) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(clerkId);
|
||||
if (clerk == null || !tenantId.equals(clerk.getTenantId())) {
|
||||
return false;
|
||||
}
|
||||
if (!StringUtils.hasText(clerk.getGroupId())) {
|
||||
return false;
|
||||
}
|
||||
PlayPersonnelGroupInfoEntity group = groupInfoService.getById(clerk.getGroupId());
|
||||
if (group == null || !tenantId.equals(group.getTenantId())) {
|
||||
return false;
|
||||
}
|
||||
return loginUser.getUserId() != null && loginUser.getUserId().equals(group.getSysUserId());
|
||||
}
|
||||
|
||||
public boolean canReadAdjustmentByIdempotencyKey(String key) {
|
||||
if (!StringUtils.hasText(key)) {
|
||||
return false;
|
||||
}
|
||||
if (isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
EarningsLineAdjustmentEntity adjustment = earningsAdjustmentService.getByIdempotencyKey(tenantId, key);
|
||||
if (adjustment == null) {
|
||||
return true;
|
||||
}
|
||||
return canManageClerk(adjustment.getClerkId());
|
||||
}
|
||||
|
||||
public boolean canRejectWithdrawal(String requestId) {
|
||||
if (!StringUtils.hasText(requestId)) {
|
||||
return false;
|
||||
}
|
||||
if (isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(requestId);
|
||||
if (req == null) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
if (!tenantId.equals(req.getTenantId())) {
|
||||
return true;
|
||||
}
|
||||
return canManageClerk(req.getClerkId());
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin() {
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
return loginUser != null && loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public interface IEarningsAdjustmentService extends IService<EarningsLineAdjustmentEntity> {
|
||||
|
||||
EarningsLineAdjustmentEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
String idempotencyKey,
|
||||
LocalDateTime effectiveTime);
|
||||
|
||||
EarningsLineAdjustmentEntity getByIdempotencyKey(String tenantId, String idempotencyKey);
|
||||
|
||||
void triggerApplyAsync(String adjustmentId);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
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 java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface IEarningsDeductionBatchService extends IService<EarningsDeductionBatchEntity> {
|
||||
|
||||
EarningsDeductionBatchEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey);
|
||||
|
||||
EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey);
|
||||
|
||||
List<EarningsDeductionItemEntity> listItems(String tenantId, String batchId);
|
||||
|
||||
IPage<EarningsDeductionItemEntity> pageItems(String tenantId, String batchId, int pageNum, int pageSize);
|
||||
|
||||
List<EarningsDeductionBatchLogEntity> listLogs(String tenantId, String batchId);
|
||||
|
||||
void triggerApplyAsync(String batchId);
|
||||
|
||||
void retryAsync(String batchId);
|
||||
|
||||
EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId);
|
||||
}
|
||||
@@ -10,4 +10,6 @@ public interface IWithdrawalService extends IService<WithdrawalRequestEntity> {
|
||||
void markManualSuccess(String requestId, String operatorBy);
|
||||
|
||||
void autoPayout(String requestId);
|
||||
|
||||
void reject(String requestId, String reason);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineAdjustmentMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class EarningsAdjustmentServiceImpl extends ServiceImpl<EarningsLineAdjustmentMapper, EarningsLineAdjustmentEntity>
|
||||
implements IEarningsAdjustmentService {
|
||||
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Resource(name = "threadPoolTaskExecutor")
|
||||
private ThreadPoolTaskExecutor executor;
|
||||
|
||||
@Override
|
||||
public EarningsLineAdjustmentEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
String idempotencyKey,
|
||||
LocalDateTime effectiveTime) {
|
||||
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(clerkId)) {
|
||||
throw new CustomException("参数缺失");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) {
|
||||
throw new CustomException("amount 必须非0");
|
||||
}
|
||||
if (reasonType == null) {
|
||||
throw new CustomException("reasonType 必填");
|
||||
}
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
throw new CustomException("Idempotency-Key 必填");
|
||||
}
|
||||
String trimmedReason = reasonDescription == null ? "" : reasonDescription.trim();
|
||||
if (!StringUtils.hasText(trimmedReason)) {
|
||||
throw new CustomException("reasonDescription 必填");
|
||||
}
|
||||
|
||||
LocalDateTime resolvedEffective = effectiveTime == null ? LocalDateTime.now() : effectiveTime;
|
||||
// Idempotency hash must be stable across retries; do NOT inject server "now" into the hash.
|
||||
// If client omits effectiveTime, we treat it as "unspecified" for hashing.
|
||||
String requestHash = computeRequestHash(tenantId, clerkId, amount, reasonType, trimmedReason, effectiveTime);
|
||||
|
||||
EarningsLineAdjustmentEntity existing = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (existing != null) {
|
||||
if (existing.getRequestHash() != null && !existing.getRequestHash().equals(requestHash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
EarningsLineAdjustmentEntity created = new EarningsLineAdjustmentEntity();
|
||||
created.setId(IdUtils.getUuid());
|
||||
created.setTenantId(tenantId);
|
||||
created.setClerkId(clerkId);
|
||||
created.setAmount(amount);
|
||||
created.setReasonType(reasonType);
|
||||
created.setReasonDescription(trimmedReason);
|
||||
created.setStatus(EarningsAdjustmentStatus.PROCESSING);
|
||||
created.setIdempotencyKey(idempotencyKey);
|
||||
created.setRequestHash(requestHash);
|
||||
created.setEffectiveTime(resolvedEffective);
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
created.setCreatedTime(now);
|
||||
created.setUpdatedTime(now);
|
||||
|
||||
try {
|
||||
this.save(created);
|
||||
return created;
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsLineAdjustmentEntity raced = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (raced == null) {
|
||||
throw dup;
|
||||
}
|
||||
if (raced.getRequestHash() != null && !raced.getRequestHash().equals(requestHash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return raced;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsLineAdjustmentEntity getByIdempotencyKey(String tenantId, String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(idempotencyKey)) {
|
||||
return null;
|
||||
}
|
||||
return this.getOne(Wrappers.lambdaQuery(EarningsLineAdjustmentEntity.class)
|
||||
.eq(EarningsLineAdjustmentEntity::getTenantId, tenantId)
|
||||
.eq(EarningsLineAdjustmentEntity::getIdempotencyKey, idempotencyKey)
|
||||
.eq(EarningsLineAdjustmentEntity::getDeleted, false)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void triggerApplyAsync(String adjustmentId) {
|
||||
if (!StringUtils.hasText(adjustmentId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
applyOnce(adjustmentId);
|
||||
} catch (Exception ignored) {
|
||||
// Intentionally swallow: async apply must not crash request thread.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyOnce(String adjustmentId) {
|
||||
EarningsLineAdjustmentEntity adjustment = this.getById(adjustmentId);
|
||||
if (adjustment == null) {
|
||||
return;
|
||||
}
|
||||
if (adjustment.getStatus() == EarningsAdjustmentStatus.APPLIED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
EarningsLineEntity line = new EarningsLineEntity();
|
||||
line.setId(IdUtils.getUuid());
|
||||
line.setTenantId(adjustment.getTenantId());
|
||||
line.setClerkId(adjustment.getClerkId());
|
||||
line.setOrderId(null);
|
||||
line.setSourceType(EarningsSourceType.ADJUSTMENT);
|
||||
line.setSourceId(adjustment.getId());
|
||||
line.setAmount(adjustment.getAmount());
|
||||
line.setEarningType(EarningsType.ADJUSTMENT);
|
||||
line.setStatus("available");
|
||||
LocalDateTime effective = adjustment.getEffectiveTime() == null ? LocalDateTime.now() : adjustment.getEffectiveTime();
|
||||
line.setUnlockTime(effective);
|
||||
Date now = new Date();
|
||||
line.setCreatedTime(now);
|
||||
line.setUpdatedTime(now);
|
||||
line.setDeleted(false);
|
||||
|
||||
earningsService.save(line);
|
||||
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.APPLIED);
|
||||
update.setAppliedTime(LocalDateTime.now());
|
||||
this.updateById(update);
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.APPLIED);
|
||||
update.setAppliedTime(LocalDateTime.now());
|
||||
this.updateById(update);
|
||||
} catch (Exception ex) {
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.FAILED);
|
||||
update.setFailureReason(ex.getMessage());
|
||||
this.updateById(update);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private String computeRequestHash(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
LocalDateTime effectiveTime) {
|
||||
String normalizedAmount = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
String rawEffective = effectiveTime == null ? "" : effectiveTime.toString();
|
||||
String raw = tenantId + "|" + clerkId + "|" + normalizedAmount + "|" + reasonType.name() + "|" + reasonDescription + "|" + rawEffective;
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
return toHex(bytes);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("hash failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
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.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionLogEventType;
|
||||
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.admin.modules.withdraw.mapper.EarningsDeductionBatchLogMapper;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchMapper;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsDeductionBatchService;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class EarningsDeductionBatchServiceImpl
|
||||
extends ServiceImpl<EarningsDeductionBatchMapper, EarningsDeductionBatchEntity>
|
||||
implements IEarningsDeductionBatchService {
|
||||
|
||||
@Resource
|
||||
private EarningsDeductionItemMapper itemMapper;
|
||||
|
||||
@Resource
|
||||
private EarningsDeductionBatchLogMapper logMapper;
|
||||
|
||||
@Resource
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Resource(name = "threadPoolTaskExecutor")
|
||||
private ThreadPoolTaskExecutor executor;
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey) {
|
||||
|
||||
validateCreateRequest(tenantId, clerkIds, beginTime, endTime, ruleType, ruleValue, operation, reasonDescription, idempotencyKey);
|
||||
|
||||
List<String> normalizedClerks = normalizeClerkIds(clerkIds);
|
||||
String hash = computeRequestHash(tenantId, normalizedClerks, beginTime, endTime, ruleType, ruleValue, operation, reasonDescription);
|
||||
|
||||
EarningsDeductionBatchEntity existing = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (existing != null) {
|
||||
if (existing.getRequestHash() != null && !existing.getRequestHash().equals(hash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
EarningsDeductionBatchEntity created = new EarningsDeductionBatchEntity();
|
||||
created.setId(IdUtils.getUuid());
|
||||
created.setTenantId(tenantId);
|
||||
created.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
created.setWindowBeginTime(beginTime);
|
||||
created.setWindowEndTime(endTime);
|
||||
created.setRuleType(ruleType);
|
||||
created.setRuleValue(ruleValue.setScale(2, RoundingMode.HALF_UP));
|
||||
created.setOperation(operation);
|
||||
created.setReasonDescription(reasonDescription.trim());
|
||||
created.setIdempotencyKey(idempotencyKey);
|
||||
created.setRequestHash(hash);
|
||||
Date now = new Date();
|
||||
created.setCreatedTime(now);
|
||||
created.setUpdatedTime(now);
|
||||
created.setDeleted(false);
|
||||
|
||||
try {
|
||||
this.save(created);
|
||||
ensureItems(created, normalizedClerks);
|
||||
logOnce(created, EarningsDeductionLogEventType.CREATED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"批次已创建", buildPayload(created, normalizedClerks));
|
||||
return created;
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsDeductionBatchEntity raced = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (raced == null) {
|
||||
throw dup;
|
||||
}
|
||||
if (raced.getRequestHash() != null && !raced.getRequestHash().equals(hash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
ensureItems(raced, normalizedClerks);
|
||||
logOnce(raced, EarningsDeductionLogEventType.CREATED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"批次已创建", buildPayload(raced, normalizedClerks));
|
||||
return raced;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(idempotencyKey)) {
|
||||
return null;
|
||||
}
|
||||
return this.getOne(Wrappers.lambdaQuery(EarningsDeductionBatchEntity.class)
|
||||
.eq(EarningsDeductionBatchEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionBatchEntity::getIdempotencyKey, idempotencyKey)
|
||||
.eq(EarningsDeductionBatchEntity::getDeleted, false)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EarningsDeductionItemEntity> listItems(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionItemEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<EarningsDeductionItemEntity> pageItems(String tenantId, String batchId, int pageNum, int pageSize) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new Page<>(pageNum, pageSize);
|
||||
}
|
||||
return itemMapper.selectPage(new Page<>(pageNum, pageSize), Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionItemEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EarningsDeductionBatchLogEntity> listLogs(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return logMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
|
||||
.eq(EarningsDeductionBatchLogEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionBatchLogEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionBatchLogEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionBatchLogEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void triggerApplyAsync(String batchId) {
|
||||
if (!StringUtils.hasText(batchId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
applyOnce(batchId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void retryAsync(String batchId) {
|
||||
if (!StringUtils.hasText(batchId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
retryOnce(batchId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return null;
|
||||
}
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null || !tenantId.equals(batch.getTenantId())) {
|
||||
return null;
|
||||
}
|
||||
reconcile(batch);
|
||||
return this.getById(batchId);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyOnce(String batchId) {
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
logOnce(batch, EarningsDeductionLogEventType.APPLY_STARTED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"开始执行批次", null);
|
||||
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null || !StringUtils.hasText(item.getClerkId())) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.hasText(item.getAdjustmentId())) {
|
||||
adjustmentService.triggerApplyAsync(item.getAdjustmentId());
|
||||
continue;
|
||||
}
|
||||
BigDecimal amount = item.getApplyAmount() == null ? BigDecimal.ZERO : item.getApplyAmount();
|
||||
if (amount.compareTo(BigDecimal.ZERO) == 0) {
|
||||
markItemFailed(item.getId(), "applyAmount=0");
|
||||
continue;
|
||||
}
|
||||
String idempotencyKey = "deduct:" + batch.getId() + ":" + item.getClerkId();
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.createOrGetProcessing(
|
||||
batch.getTenantId(),
|
||||
item.getClerkId(),
|
||||
amount,
|
||||
EarningsAdjustmentReasonType.MANUAL,
|
||||
batch.getReasonDescription(),
|
||||
idempotencyKey,
|
||||
null);
|
||||
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setAdjustmentId(adjustment.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
|
||||
adjustmentService.triggerApplyAsync(adjustment.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void retryOnce(String batchId) {
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null) {
|
||||
return;
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.RETRY_STARTED, batch.getStatus() == null ? null : batch.getStatus().name(),
|
||||
EarningsDeductionStatus.PROCESSING.name(), "开始重试失败项", null);
|
||||
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
continue;
|
||||
}
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
continue;
|
||||
}
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
adjustmentService.triggerApplyAsync(item.getAdjustmentId());
|
||||
}
|
||||
|
||||
EarningsDeductionBatchEntity patchBatch = new EarningsDeductionBatchEntity();
|
||||
patchBatch.setId(batch.getId());
|
||||
patchBatch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
this.updateById(patchBatch);
|
||||
}
|
||||
|
||||
private void reconcile(EarningsDeductionBatchEntity batch) {
|
||||
if (batch == null || !StringUtils.hasText(batch.getId())) {
|
||||
return;
|
||||
}
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
AtomicBoolean anyFailed = new AtomicBoolean(false);
|
||||
AtomicBoolean anyProcessing = new AtomicBoolean(false);
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
anyProcessing.set(true);
|
||||
continue;
|
||||
}
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.getById(item.getAdjustmentId());
|
||||
if (adjustment == null || adjustment.getStatus() == null) {
|
||||
anyProcessing.set(true);
|
||||
continue;
|
||||
}
|
||||
if (adjustment.getStatus() == com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus.APPLIED) {
|
||||
if (item.getStatus() != EarningsDeductionStatus.APPLIED) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.APPLIED);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
} else if (adjustment.getStatus() == com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus.FAILED) {
|
||||
anyFailed.set(true);
|
||||
if (item.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
patch.setFailureReason(adjustment.getFailureReason());
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.ITEM_FAILED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.FAILED.name(), "明细失败", JSON.toJSONString(Objects.requireNonNullElse(adjustment.getFailureReason(), "")));
|
||||
} else {
|
||||
anyProcessing.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (anyFailed.get()) {
|
||||
if (batch.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
EarningsDeductionBatchEntity patch = new EarningsDeductionBatchEntity();
|
||||
patch.setId(batch.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
this.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.BATCH_FAILED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.FAILED.name(), "批次失败", null);
|
||||
return;
|
||||
}
|
||||
if (anyProcessing.get()) {
|
||||
return;
|
||||
}
|
||||
if (batch.getStatus() != EarningsDeductionStatus.APPLIED) {
|
||||
EarningsDeductionBatchEntity patch = new EarningsDeductionBatchEntity();
|
||||
patch.setId(batch.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.APPLIED);
|
||||
this.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.BATCH_APPLIED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.APPLIED.name(), "批次完成", null);
|
||||
}
|
||||
|
||||
private void ensureItems(EarningsDeductionBatchEntity batch, List<String> clerkIds) {
|
||||
if (batch == null || !StringUtils.hasText(batch.getId()) || CollectionUtils.isEmpty(clerkIds)) {
|
||||
return;
|
||||
}
|
||||
for (String clerkId : clerkIds) {
|
||||
if (!StringUtils.hasText(clerkId)) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal base = itemMapper.sumOrderPositiveBase(batch.getTenantId(), clerkId, batch.getWindowBeginTime(), batch.getWindowEndTime());
|
||||
BigDecimal baseAmount = base == null ? BigDecimal.ZERO : base;
|
||||
BigDecimal applyAmount = calculateApplyAmount(batch.getRuleType(), batch.getRuleValue(), batch.getOperation(), baseAmount);
|
||||
|
||||
EarningsDeductionItemEntity item = new EarningsDeductionItemEntity();
|
||||
item.setId(IdUtils.getUuid());
|
||||
item.setBatchId(batch.getId());
|
||||
item.setTenantId(batch.getTenantId());
|
||||
item.setClerkId(clerkId);
|
||||
item.setBaseAmount(baseAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
item.setApplyAmount(applyAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
item.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
item.setDeleted(false);
|
||||
try {
|
||||
itemMapper.insert(item);
|
||||
} catch (DuplicateKeyException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void markItemFailed(String itemId, String reason) {
|
||||
if (!StringUtils.hasText(itemId)) {
|
||||
return;
|
||||
}
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(itemId);
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
patch.setFailureReason(reason);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
|
||||
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 void validateCreateRequest(String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
throw new CustomException("tenant missing");
|
||||
}
|
||||
if (CollectionUtils.isEmpty(clerkIds)) {
|
||||
throw new CustomException("clerkIds required");
|
||||
}
|
||||
if (beginTime == null || endTime == null) {
|
||||
throw new CustomException("time range required");
|
||||
}
|
||||
if (beginTime.isAfter(endTime)) {
|
||||
throw new CustomException("beginTime must be <= endTime");
|
||||
}
|
||||
if (ruleType == null) {
|
||||
throw new CustomException("ruleType required");
|
||||
}
|
||||
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
|
||||
throw new CustomException("ruleValue must be non-zero");
|
||||
}
|
||||
if (operation == null) {
|
||||
throw new CustomException("operation required");
|
||||
}
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
throw new CustomException("Idempotency-Key required");
|
||||
}
|
||||
if (!StringUtils.hasText(reasonDescription) || !StringUtils.hasText(reasonDescription.trim())) {
|
||||
throw new CustomException("reasonDescription required");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> normalizeClerkIds(List<String> clerkIds) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (CollectionUtils.isEmpty(clerkIds)) {
|
||||
return result;
|
||||
}
|
||||
for (String id : clerkIds) {
|
||||
if (!StringUtils.hasText(id)) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = id.trim();
|
||||
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
result.sort(String::compareTo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildPayload(EarningsDeductionBatchEntity batch, List<String> clerkIds) {
|
||||
if (batch == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> resolvedClerkIds = clerkIds == null ? List.of() : clerkIds;
|
||||
return JSON.toJSONString(new Object() {
|
||||
public final String batchId = batch.getId();
|
||||
public final String ruleType = batch.getRuleType() == null ? null : batch.getRuleType().name();
|
||||
public final String operation = batch.getOperation() == null ? null : batch.getOperation().name();
|
||||
public final BigDecimal ruleValue = batch.getRuleValue();
|
||||
public final LocalDateTime beginTime = batch.getWindowBeginTime();
|
||||
public final LocalDateTime endTime = batch.getWindowEndTime();
|
||||
public final String reasonDescription = batch.getReasonDescription();
|
||||
public final List<String> clerkIds = resolvedClerkIds;
|
||||
});
|
||||
}
|
||||
|
||||
private void logOnce(EarningsDeductionBatchEntity batch,
|
||||
EarningsDeductionLogEventType eventType,
|
||||
String from,
|
||||
String to,
|
||||
String message,
|
||||
String payload) {
|
||||
if (batch == null || eventType == null) {
|
||||
return;
|
||||
}
|
||||
Long count = logMapper.selectCount(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
|
||||
.eq(EarningsDeductionBatchLogEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionBatchLogEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionBatchLogEntity::getEventType, eventType)
|
||||
.eq(EarningsDeductionBatchLogEntity::getDeleted, false));
|
||||
if (count != null && count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
EarningsDeductionBatchLogEntity log = new EarningsDeductionBatchLogEntity();
|
||||
log.setId(IdUtils.getUuid());
|
||||
log.setTenantId(batch.getTenantId());
|
||||
log.setBatchId(batch.getId());
|
||||
log.setEventType(eventType);
|
||||
log.setStatusFrom(from);
|
||||
log.setStatusTo(to);
|
||||
log.setMessage(message);
|
||||
log.setPayload(payload);
|
||||
log.setDeleted(false);
|
||||
logMapper.insert(log);
|
||||
}
|
||||
|
||||
private String computeRequestHash(String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription) {
|
||||
String normalizedRule = (ruleValue == null ? BigDecimal.ZERO : ruleValue).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
String raw = tenantId + "|" +
|
||||
String.join(",", clerkIds) + "|" +
|
||||
beginTime.toString() + "|" +
|
||||
endTime.toString() + "|" +
|
||||
(ruleType == null ? "" : ruleType.name()) + "|" +
|
||||
normalizedRule + "|" +
|
||||
(operation == null ? "" : operation.name()) + "|" +
|
||||
reasonDescription.trim();
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
return toHex(bytes);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("hash failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
@@ -48,6 +49,8 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
line.setTenantId(orderInfo.getTenantId());
|
||||
line.setClerkId(orderInfo.getAcceptBy());
|
||||
line.setOrderId(orderInfo.getId());
|
||||
line.setSourceType(EarningsSourceType.ORDER);
|
||||
line.setSourceId(orderInfo.getId());
|
||||
line.setAmount(amount);
|
||||
line.setEarningType(EarningsType.ORDER);
|
||||
line.setUnlockTime(unlockTime);
|
||||
@@ -85,6 +88,14 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
public List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now) {
|
||||
// pick oldest unlocked first (status in available or frozen with unlock<=now)
|
||||
List<EarningsLineEntity> list = this.baseMapper.selectWithdrawableLines(clerkId, now);
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (EarningsLineEntity line : list) {
|
||||
BigDecimal value = line.getAmount() == null ? BigDecimal.ZERO : line.getAmount();
|
||||
total = total.add(value);
|
||||
}
|
||||
if (total.compareTo(amount) < 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
BigDecimal acc = BigDecimal.ZERO;
|
||||
List<EarningsLineEntity> picked = new ArrayList<>();
|
||||
for (EarningsLineEntity e : list) {
|
||||
@@ -140,6 +151,8 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
line.setOrderId(orderId);
|
||||
line.setTenantId(tenantId);
|
||||
line.setClerkId(targetClerkId);
|
||||
line.setSourceType(EarningsSourceType.ORDER);
|
||||
line.setSourceId(orderId);
|
||||
line.setAmount(normalized.negate());
|
||||
line.setEarningType(EarningsType.ADJUSTMENT);
|
||||
line.setStatus(resolvedStatus);
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsLineStatus;
|
||||
import com.starry.admin.modules.withdraw.enums.WithdrawalRequestStatus;
|
||||
import com.starry.admin.modules.withdraw.mapper.WithdrawalRequestMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
@@ -43,6 +45,20 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
|
||||
WithdrawalRequestEntity active = this.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();
|
||||
if (active != null) {
|
||||
throw new CustomException("仅可同时存在一笔提现申请,请等待当前申请处理完成后再提交");
|
||||
}
|
||||
|
||||
ClerkPayeeProfileEntity payeeProfile = clerkPayeeProfileService.getByClerk(tenantId, clerkId);
|
||||
if (payeeProfile == null || !StringUtils.hasText(payeeProfile.getQrCodeUrl())) {
|
||||
throw new CustomException("请先上传支付宝收款码");
|
||||
@@ -66,7 +82,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
boolean updated = earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.eq(EarningsLineEntity::getStatus, line.getStatus()) // Verify status unchanged
|
||||
.set(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.set(EarningsLineEntity::getWithdrawalId, tempWithdrawalId));
|
||||
if (!updated) {
|
||||
// Another request already took this line
|
||||
@@ -88,7 +104,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
req.setNetAmount(amount);
|
||||
req.setDestAccount(payeeProfile.getDisplayName());
|
||||
req.setPayeeSnapshot(snapshotJson);
|
||||
req.setStatus("pending");
|
||||
req.setStatus(WithdrawalRequestStatus.PENDING.getCode());
|
||||
req.setOutBizNo(req.getId());
|
||||
this.save(req);
|
||||
|
||||
@@ -120,22 +136,23 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void markManualSuccess(String requestId, String operatorBy) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (!"pending".equals(req.getStatus()) && !"processing".equals(req.getStatus())) {
|
||||
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())
|
||||
&& !WithdrawalRequestStatus.PROCESSING.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("当前状态不可操作");
|
||||
}
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("success");
|
||||
update.setStatus(WithdrawalRequestStatus.SUCCESS.getCode());
|
||||
this.updateById(update);
|
||||
|
||||
// Set reserved earnings lines to withdrawn
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||
.eq(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getStatus, "withdrawn"));
|
||||
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWN.getCode()));
|
||||
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_SUCCESS", req.getStatus(), "success",
|
||||
"PAYOUT_SUCCESS", req.getStatus(), WithdrawalRequestStatus.SUCCESS.getCode(),
|
||||
"手动打款成功,操作人=" + operatorBy, null);
|
||||
}
|
||||
|
||||
@@ -144,22 +161,62 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void autoPayout(String requestId) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (!"pending".equals(req.getStatus())) {
|
||||
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("当前状态不可自动打款");
|
||||
}
|
||||
// Transition to processing and log
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("processing");
|
||||
update.setStatus(WithdrawalRequestStatus.PROCESSING.getCode());
|
||||
this.updateById(update);
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_REQUESTED", req.getStatus(), "processing",
|
||||
"PAYOUT_REQUESTED", req.getStatus(), WithdrawalRequestStatus.PROCESSING.getCode(),
|
||||
"发起支付宝打款(未实现)", null);
|
||||
|
||||
// Not implemented yet
|
||||
throw new UnsupportedOperationException("Alipay payout not implemented");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reject(String requestId, String reason) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (WithdrawalRequestStatus.SUCCESS.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("已成功的提现不可拒绝");
|
||||
}
|
||||
if (WithdrawalRequestStatus.CANCELED.getCode().equals(req.getStatus())
|
||||
|| WithdrawalRequestStatus.REJECTED.getCode().equals(req.getStatus())) {
|
||||
return;
|
||||
}
|
||||
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus(WithdrawalRequestStatus.CANCELED.getCode());
|
||||
update.setFailureReason(StringUtils.hasText(reason) ? reason : "rejected");
|
||||
this.updateById(update);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
|
||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.list();
|
||||
for (EarningsLineEntity line : lines) {
|
||||
LocalDateTime unlock = line.getUnlockTime();
|
||||
String restored = unlock != null && unlock.isAfter(now)
|
||||
? EarningsLineStatus.FROZEN.getCode()
|
||||
: EarningsLineStatus.AVAILABLE.getCode();
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.set(EarningsLineEntity::getWithdrawalId, null)
|
||||
.set(EarningsLineEntity::getStatus, restored));
|
||||
}
|
||||
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_REJECTED", req.getStatus(), update.getStatus(),
|
||||
"拒绝提现,原因=" + (reason == null ? "" : reason), null);
|
||||
}
|
||||
|
||||
private String buildPayeeSnapshot(ClerkPayeeProfileEntity profile, LocalDateTime confirmedAt) {
|
||||
PayeeSnapshotVo snapshot = new PayeeSnapshotVo();
|
||||
snapshot.setChannel(profile.getChannel());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.starry.admin.modules.withdraw.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -15,6 +16,13 @@ public class ClerkEarningLineVo {
|
||||
private EarningsType earningType;
|
||||
private String withdrawalId;
|
||||
|
||||
private EarningsAdjustmentReasonType adjustmentReasonType;
|
||||
private String adjustmentReasonDescription;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime adjustmentEffectiveTime;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime unlockTime;
|
||||
|
||||
@@ -6,6 +6,7 @@ import io.swagger.annotations.ApiModelProperty;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
@@ -23,4 +24,26 @@ public class ClerkWithdrawBalanceVo {
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime nextUnlockAt;
|
||||
|
||||
@ApiModelProperty("是否提现锁定(仅允许同时存在一笔在途申请)")
|
||||
private Boolean withdrawLocked;
|
||||
|
||||
@ApiModelProperty("锁定原因")
|
||||
private String withdrawLockReason;
|
||||
|
||||
@ApiModelProperty("当前在途申请(pending/processing)")
|
||||
private ActiveRequest activeRequest;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ActiveRequest {
|
||||
@ApiModelProperty("申请金额")
|
||||
private BigDecimal amount;
|
||||
@ApiModelProperty("状态 pending/processing")
|
||||
private String status;
|
||||
@ApiModelProperty("提交时间 yyyy-MM-dd HH:mm:ss")
|
||||
private String createdTime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,14 @@ apitest:
|
||||
user-header: X-Test-User
|
||||
defaults:
|
||||
tenant-id: tenant-apitest
|
||||
user-id: apitest-user
|
||||
# Must exist in DB. ApiTestDataSeeder seeds DEFAULT_ADMIN_USER_ID=user-apitest-admin.
|
||||
user-id: user-apitest-admin
|
||||
roles:
|
||||
- ROLE_TESTER
|
||||
permissions: []
|
||||
permissions:
|
||||
- withdraw:deduction:create
|
||||
- withdraw:deduction:read
|
||||
|
||||
# E2E/ApiTest: skip real WeChat subscribe check to keep flows deterministic.
|
||||
wechat:
|
||||
subscribe-check-enabled: false
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Earnings adjustments: introduce generic adjustment header table + source identity for earnings lines.
|
||||
|
||||
-- 1) Extend ledger to support non-order sources (e.g. admin adjustments / rewards / punishments).
|
||||
-- We keep order_id for ORDER source for backward compatibility, but allow it to be NULL for adjustment lines.
|
||||
|
||||
-- Drop legacy uniqueness keyed by order_id, since order_id can now be NULL and we want uniqueness by source identity.
|
||||
ALTER TABLE `play_earnings_line`
|
||||
DROP INDEX `uk_tenant_order_clerk_type`;
|
||||
|
||||
ALTER TABLE `play_earnings_line`
|
||||
MODIFY COLUMN `order_id` varchar(32) NULL COMMENT '订单ID(source_type=ORDER 时必填)',
|
||||
ADD COLUMN `source_type` varchar(16) NOT NULL DEFAULT 'ORDER' COMMENT '来源类型(ORDER/ADJUSTMENT)' AFTER `order_id`,
|
||||
ADD COLUMN `source_id` varchar(32) DEFAULT NULL COMMENT '来源ID(source_type=ORDER 时等于 order_id;source_type=ADJUSTMENT 时等于 adjustment_id)' AFTER `source_type`;
|
||||
|
||||
-- Backfill existing rows to ORDER source.
|
||||
UPDATE `play_earnings_line`
|
||||
SET `source_id` = `order_id`
|
||||
WHERE (`source_id` IS NULL)
|
||||
AND `order_id` IS NOT NULL
|
||||
AND `deleted` = 0;
|
||||
|
||||
-- New uniqueness: one line per source identity per clerk/type (ignoring logical delete).
|
||||
ALTER TABLE `play_earnings_line`
|
||||
ADD UNIQUE KEY `uk_tenant_source_clerk_type` (`tenant_id`, `source_type`, `source_id`, `clerk_id`, `earning_type`, `deleted`);
|
||||
|
||||
-- 2) Adjustment header table (idempotent, async lifecycle).
|
||||
CREATE TABLE IF NOT EXISTS `play_earnings_line_adjustment` (
|
||||
`id` varchar(32) NOT NULL COMMENT 'UUID',
|
||||
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
|
||||
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
|
||||
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(可正可负,不允许0)',
|
||||
`reason_type` varchar(32) NOT NULL COMMENT '原因类型(Enum, hard-coded for now)',
|
||||
`reason_description` varchar(512) NOT NULL COMMENT '原因描述(操作时输入)',
|
||||
`status` varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/APPLIED/FAILED',
|
||||
`idempotency_key` varchar(64) DEFAULT NULL COMMENT '幂等Key(tenant范围内唯一;为空则不幂等)',
|
||||
`request_hash` varchar(64) NOT NULL DEFAULT '' COMMENT '请求摘要(用于检测同key不同body)',
|
||||
`effective_time` datetime NOT NULL COMMENT '生效时间(用于统计窗口;默认 now)',
|
||||
`applied_time` datetime DEFAULT NULL COMMENT '落账完成时间',
|
||||
`failure_reason` varchar(512) DEFAULT NULL COMMENT '失败原因(FAILED 时)',
|
||||
`created_by` varchar(32) DEFAULT NULL,
|
||||
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_by` varchar(32) DEFAULT NULL,
|
||||
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除',
|
||||
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_tenant_idempotency` (`tenant_id`, `idempotency_key`, `deleted`) USING BTREE,
|
||||
KEY `idx_adjustment_tenant_clerk_time` (`tenant_id`, `clerk_id`, `effective_time`) USING BTREE,
|
||||
KEY `idx_adjustment_tenant_status` (`tenant_id`, `status`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益调整(Reward/Punishment/Correction统一抽象)';
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Earnings deduction batches: apply fixed/percentage bonus or punishment across clerks in a time window.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `play_earnings_deduction_batch` (
|
||||
`id` varchar(32) NOT NULL COMMENT 'UUID',
|
||||
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
|
||||
`status` varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/APPLIED/FAILED',
|
||||
`begin_time` datetime NOT NULL COMMENT '统计起始时间(按订单结束时间)',
|
||||
`end_time` datetime NOT NULL COMMENT '统计结束时间(按订单结束时间)',
|
||||
`rule_type` varchar(16) NOT NULL COMMENT '规则类型:FIXED/PERCENTAGE',
|
||||
`rule_value` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '规则值(FIXED=金额;PERCENTAGE=百分比)',
|
||||
`operation` varchar(16) NOT NULL DEFAULT 'PUNISHMENT' COMMENT '操作类型:BONUS/PUNISHMENT',
|
||||
`reason_description` varchar(512) NOT NULL COMMENT '原因描述(操作时输入)',
|
||||
`idempotency_key` varchar(64) DEFAULT NULL COMMENT '幂等Key(tenant范围内唯一)',
|
||||
`request_hash` varchar(64) NOT NULL DEFAULT '' COMMENT '请求摘要(用于检测同key不同body)',
|
||||
`created_by` varchar(32) DEFAULT NULL,
|
||||
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_by` varchar(32) DEFAULT NULL,
|
||||
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除',
|
||||
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_tenant_idempotency` (`tenant_id`, `idempotency_key`, `deleted`) USING BTREE,
|
||||
KEY `idx_deduct_batch_tenant_status_time` (`tenant_id`, `status`, `created_time`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益批量扣减批次';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `play_earnings_deduction_item` (
|
||||
`id` varchar(32) NOT NULL COMMENT 'UUID',
|
||||
`batch_id` varchar(32) NOT NULL COMMENT '批次ID',
|
||||
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
|
||||
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
|
||||
`base_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '基数金额(仅订单收益正数)',
|
||||
`apply_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '执行金额(可正可负,不允许0)',
|
||||
`status` varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/APPLIED/FAILED',
|
||||
`adjustment_id` varchar(32) DEFAULT NULL COMMENT '关联收益调整ID(play_earnings_line_adjustment.id)',
|
||||
`failure_reason` varchar(512) DEFAULT NULL COMMENT '失败原因(FAILED 时)',
|
||||
`created_by` varchar(32) DEFAULT NULL,
|
||||
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_by` varchar(32) DEFAULT NULL,
|
||||
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除',
|
||||
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_batch_clerk` (`batch_id`, `clerk_id`, `deleted`) USING BTREE,
|
||||
KEY `idx_deduct_item_batch_status` (`batch_id`, `status`) USING BTREE,
|
||||
KEY `idx_deduct_item_tenant_clerk` (`tenant_id`, `clerk_id`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益批量扣减明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `play_earnings_deduction_batch_log` (
|
||||
`id` varchar(32) NOT NULL COMMENT 'UUID',
|
||||
`batch_id` varchar(32) NOT NULL COMMENT '批次ID',
|
||||
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
|
||||
`event_type` varchar(32) NOT NULL COMMENT '事件类型:CREATED/APPLY_STARTED/ITEM_APPLIED/ITEM_FAILED/BATCH_APPLIED/BATCH_FAILED/RETRY_STARTED',
|
||||
`status_from` varchar(16) DEFAULT NULL COMMENT '状态变更前',
|
||||
`status_to` varchar(16) DEFAULT NULL COMMENT '状态变更后',
|
||||
`message` varchar(512) DEFAULT NULL COMMENT '事件说明',
|
||||
`payload` text COMMENT '事件载荷(JSON)',
|
||||
`created_by` varchar(32) DEFAULT NULL,
|
||||
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_by` varchar(32) DEFAULT NULL,
|
||||
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除',
|
||||
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `idx_deduct_log_batch_time` (`batch_id`, `created_time`) USING BTREE,
|
||||
KEY `idx_deduct_log_tenant_batch` (`tenant_id`, `batch_id`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益批量扣减日志';
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* Authorization contract tests for admin earnings adjustments.
|
||||
*
|
||||
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
|
||||
*/
|
||||
class AdminEarningsAdjustmentAuthorizationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
private static final String PERMISSION_ADJUSTMENT_CREATE = "withdraw:adjustment:create";
|
||||
private static final String PERMISSION_ADJUSTMENT_READ = "withdraw:adjustment:read";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService groupInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
private final List<String> idempotencyKeysToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> groupIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
idempotencyKeysToCleanup.clear();
|
||||
clerkIdsToCleanup.clear();
|
||||
groupIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupAdjustmentsByIdempotencyKeys();
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
if (!groupIdsToCleanup.isEmpty()) {
|
||||
groupInfoService.removeByIds(groupIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithoutPermissionReturns403() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "20.00")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createLeaderWithPermissionCannotManageOtherGroupClerkReturns403() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(otherClerkId, "20.00")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSuperAdminBypassesPermissionAndScopeReturns202() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid())
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(SUPER_ADMIN_HEADER, "true")
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(otherClerkId, "20.00")))
|
||||
.andExpect(status().isAccepted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollWithoutReadPermissionReturns403() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "-10.00")))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossTenantPollReturns404EvenWithPermission() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "1.00")))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String createPayload(String clerkId, String amount) {
|
||||
return "{" +
|
||||
"\"clerkId\":\"" + clerkId + "\"," +
|
||||
"\"amount\":\"" + amount + "\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"auth-test\"" +
|
||||
"}";
|
||||
}
|
||||
|
||||
private String seedOtherGroupClerk() {
|
||||
String groupId = "group-auth-" + IdUtils.getUuid();
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId("leader-auth-" + IdUtils.getUuid());
|
||||
group.setSysUserCode("leader-auth");
|
||||
group.setGroupName("Auth Test Group");
|
||||
group.setLeaderName("Leader Auth");
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-auth-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-auth-" + clerkId);
|
||||
clerk.setNickname("Auth Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-auth-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
|
||||
return clerkId;
|
||||
}
|
||||
|
||||
private void cleanupAdjustmentsByIdempotencyKeys() {
|
||||
for (String key : idempotencyKeysToCleanup) {
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.getByIdempotencyKey(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID, key);
|
||||
if (adjustment != null) {
|
||||
cleanupAdjustment(adjustment.getId());
|
||||
}
|
||||
}
|
||||
idempotencyKeysToCleanup.clear();
|
||||
}
|
||||
|
||||
private void cleanupAdjustment(String adjustmentId) {
|
||||
if (adjustmentId == null) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.eq(EarningsLineEntity::getSourceId, adjustmentId)
|
||||
.remove();
|
||||
adjustmentService.removeById(adjustmentId);
|
||||
}
|
||||
|
||||
private void awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
if ("APPLIED".equals(root.path("data").path("status").asText())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.anyOf;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD contract tests for earnings adjustments (reward/punishment/unified adjustment model).
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the adjustment system is implemented end-to-end.</p>
|
||||
*/
|
||||
class AdminEarningsAdjustmentControllerApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> createdAdjustmentIds = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
deleteAdjustmentsAndLines(createdAdjustmentIds);
|
||||
createdAdjustmentIds.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAdjustmentReturns202AndProvidesPollingHandle() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"20.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"manual reward for testing\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(header().string("Location", "/admin/earnings/adjustments/idempotency/" + key))
|
||||
.andExpect(jsonPath("$.code").value(202))
|
||||
.andExpect(jsonPath("$.data").exists())
|
||||
.andExpect(jsonPath("$.data.adjustmentId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.status").value("PROCESSING"))
|
||||
.andReturn();
|
||||
|
||||
createdAdjustmentIds.add(extractAdjustmentId(result));
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollByIdempotencyKeyReturnsProcessingThenApplied() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"-10.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"manual punishment for testing\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
|
||||
// Immediately polling should return a stable representation (at least PROCESSING).
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.adjustmentId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.status").value(anyOf(is("PROCESSING"), is("APPLIED"))));
|
||||
|
||||
// After implementation, the system should eventually transition to APPLIED.
|
||||
// Poll with a bounded wait to keep the test deterministic.
|
||||
boolean applied = false;
|
||||
for (int i = 0; i < 40; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
applied = true;
|
||||
break;
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
assertThat(applied).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeySameBodyIsIdempotent() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"30.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"idempotent create\"" +
|
||||
"}";
|
||||
|
||||
MvcResult first = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
MvcResult second = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
JsonNode firstRoot = objectMapper.readTree(first.getResponse().getContentAsString());
|
||||
JsonNode secondRoot = objectMapper.readTree(second.getResponse().getContentAsString());
|
||||
String firstId = firstRoot.path("data").path("adjustmentId").asText();
|
||||
String secondId = secondRoot.path("data").path("adjustmentId").asText();
|
||||
assertThat(firstId).isNotBlank();
|
||||
assertThat(secondId).isEqualTo(firstId);
|
||||
createdAdjustmentIds.add(firstId);
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyDifferentBodyReturns409() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payloadA = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"30.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"first payload\"" +
|
||||
"}";
|
||||
String payloadB = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"31.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"different payload\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadA))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadB))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingIdempotencyKeyReturns400() throws Exception {
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"20.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"missing idempotency\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsZeroAmount() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"0.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"zero amount\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingReasonType() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"reasonDescription\":\"missing reason type\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankReasonDescription() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\" \"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentCreatesWithSameKeyReturnSameAdjustmentIdAndDoNotDuplicate() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"9.99\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"concurrent\"" +
|
||||
"}";
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<String> call = () -> {
|
||||
MvcResult result = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
};
|
||||
List<Future<String>> futures = new ArrayList<>();
|
||||
futures.add(pool.submit(call));
|
||||
futures.add(pool.submit(call));
|
||||
|
||||
String a = futures.get(0).get();
|
||||
String b = futures.get(1).get();
|
||||
assertThat(a).isNotBlank();
|
||||
assertThat(b).isEqualTo(a);
|
||||
createdAdjustmentIds.add(a);
|
||||
awaitApplied(key);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollMissingIdempotencyKeyReturns404() throws Exception {
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + UUID.randomUUID())
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyIsTenantScoped() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"1.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"tenant scope\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String extractAdjustmentId(MvcResult result) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private void deleteAdjustmentsAndLines(Collection<String> adjustmentIds) {
|
||||
if (adjustmentIds == null || adjustmentIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* Authorization contract tests for admin batch deductions.
|
||||
*/
|
||||
class AdminEarningsDeductionBatchAuthorizationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
|
||||
private static final String PERMISSION_CREATE = "withdraw:deduction:create";
|
||||
private static final String PERMISSION_READ = "withdraw:deduction:read";
|
||||
|
||||
private static final String BASE_URL = "/admin/earnings/deductions";
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService groupInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> groupIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> orderIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> earningsIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> batchIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
groupIdsToCleanup.clear();
|
||||
clerkIdsToCleanup.clear();
|
||||
orderIdsToCleanup.clear();
|
||||
earningsIdsToCleanup.clear();
|
||||
batchIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupBatches(batchIdsToCleanup);
|
||||
if (!earningsIdsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningsIdsToCleanup);
|
||||
}
|
||||
if (!orderIdsToCleanup.isEmpty()) {
|
||||
orderInfoService.removeByIds(orderIdsToCleanup);
|
||||
}
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
if (!groupIdsToCleanup.isEmpty()) {
|
||||
groupInfoService.removeByIds(groupIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewWithoutPermissionReturns403() throws Exception {
|
||||
String payload = "{\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"2026-01-01 00:00:00\",\"endTime\":\"2026-01-07 23:59:59\"," +
|
||||
"\"ruleType\":\"FIXED\",\"amount\":\"10.00\",\"operation\":\"BONUS\",\"reasonDescription\":\"x\"}";
|
||||
mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, "leader-no-perm")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, "nope")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithoutPermissionReturns403() throws Exception {
|
||||
seedOrderAndLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now().minusDays(1), new BigDecimal("100.00"));
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = buildFixedPayload(LocalDateTime.now().minusDays(7), LocalDateTime.now(), "10.00");
|
||||
mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, "leader-no-perm")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewLeaderWithPermissionCannotManageOtherGroupClerkReturns403() throws Exception {
|
||||
String leaderUserId = "leader-preview-" + IdUtils.getUuid();
|
||||
String otherClerkId = seedClerkInOtherGroup(
|
||||
"other-group-" + IdUtils.getUuid(),
|
||||
"other-leader-" + IdUtils.getUuid(),
|
||||
"Other Group",
|
||||
otherLeaderId());
|
||||
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + otherClerkId + "\"]," +
|
||||
"\"beginTime\":\"2026-01-01 00:00:00\"," +
|
||||
"\"endTime\":\"2026-01-07 23:59:59\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"scope\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, leaderUserId)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_CREATE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderWithPermissionCanManageOwnGroupClerkReturns202() throws Exception {
|
||||
String leaderUserId = "leader-own-" + IdUtils.getUuid();
|
||||
String groupId = "group-own-" + IdUtils.getUuid();
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId(leaderUserId);
|
||||
group.setSysUserCode(leaderUserId);
|
||||
group.setGroupName("Leader Group");
|
||||
group.setLeaderName(leaderUserId);
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-own-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-" + clerkId);
|
||||
clerk.setNickname("Own Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
|
||||
seedOrderAndLine(clerkId, LocalDateTime.now().minusDays(1), new BigDecimal("100.00"));
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + clerkId + "\"]," +
|
||||
"\"beginTime\":\"2026-01-01 00:00:00\"," +
|
||||
"\"endTime\":\"2026-01-07 23:59:59\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"own\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, leaderUserId)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_CREATE + "," + PERMISSION_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
String batchId = root.path("data").path("batchId").asText();
|
||||
if (batchId != null && !batchId.isEmpty()) {
|
||||
batchIdsToCleanup.add(batchId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderWithPermissionCannotManageOtherGroupClerkReturns403() throws Exception {
|
||||
String leaderUserId = "leader-deduct-" + IdUtils.getUuid();
|
||||
String otherClerkId = seedClerkInOtherGroup("other-group-" + IdUtils.getUuid(), "other-leader-" + IdUtils.getUuid(), "Other Group", otherLeaderId());
|
||||
// Ensure the other clerk has base so that authorization is the only blocker.
|
||||
seedOrderAndLine(otherClerkId, LocalDateTime.now().minusDays(1), new BigDecimal("100.00"));
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + otherClerkId + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(LocalDateTime.now().minusDays(7)) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(LocalDateTime.now().plusSeconds(1)) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"PUNISHMENT\"," +
|
||||
"\"reasonDescription\":\"scope\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, leaderUserId)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void superAdminBypassesPermissionAndScopeReturns202() throws Exception {
|
||||
String otherClerkId = seedClerkInOtherGroup("other-group-" + IdUtils.getUuid(), "other-leader-" + IdUtils.getUuid(), "Other Group", otherLeaderId());
|
||||
seedOrderAndLine(otherClerkId, LocalDateTime.now().minusDays(1), new BigDecimal("100.00"));
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + otherClerkId + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(LocalDateTime.now().minusDays(7)) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(LocalDateTime.now().plusSeconds(1)) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"superadmin\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, "super-admin-deduct")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(SUPER_ADMIN_HEADER, "true")
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
String batchId = root.path("data").path("batchId").asText();
|
||||
if (batchId != null && !batchId.isEmpty()) {
|
||||
batchIdsToCleanup.add(batchId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void readWithoutPermissionReturns403() throws Exception {
|
||||
// Contract: read endpoints are protected even if the resource does not exist.
|
||||
mockMvc.perform(get(BASE_URL + "/idempotency/" + UUID.randomUUID())
|
||||
.header(USER_HEADER, "read-no-perm")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, "nope"))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + UUID.randomUUID() + "/items")
|
||||
.header(USER_HEADER, "read-no-perm")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, "nope"))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + UUID.randomUUID() + "/logs")
|
||||
.header(USER_HEADER, "read-no-perm")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, "nope"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderWithReadPermissionCannotReadOtherGroupBatchReturns403() throws Exception {
|
||||
String leaderUserId = "leader-read-" + IdUtils.getUuid();
|
||||
String otherClerkId = seedClerkInOtherGroup(
|
||||
"other-group-" + IdUtils.getUuid(),
|
||||
"other-leader-" + IdUtils.getUuid(),
|
||||
"Other Group",
|
||||
otherLeaderId());
|
||||
|
||||
String batchId = "batch-auth-" + IdUtils.getUuid();
|
||||
batchIdsToCleanup.add(batchId);
|
||||
seedBatchItemAndLogForOtherClerk(batchId, otherClerkId);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, leaderUserId)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_READ))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, leaderUserId)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_READ))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private String buildFixedPayload(LocalDateTime begin, LocalDateTime end, String amount) {
|
||||
return "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"" + amount + "\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"auth\"" +
|
||||
"}";
|
||||
}
|
||||
|
||||
private void seedOrderAndLine(String clerkId, LocalDateTime endTime, BigDecimal amount) {
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
String orderId = "order-deduct-auth-" + IdUtils.getUuid();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setOrderNo("AUTH-" + System.currentTimeMillis());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderType("2");
|
||||
order.setPlaceType("0");
|
||||
order.setRewardType("0");
|
||||
order.setAcceptBy(clerkId);
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
order.setOrderMoney(new BigDecimal("120.50"));
|
||||
order.setFinalAmount(order.getOrderMoney());
|
||||
order.setEstimatedRevenue(amount);
|
||||
order.setOrderSettlementState("1");
|
||||
order.setOrderEndTime(endTime);
|
||||
order.setOrderSettlementTime(endTime);
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(nowDate);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(nowDate);
|
||||
order.setDeleted(false);
|
||||
orderInfoService.save(order);
|
||||
orderIdsToCleanup.add(orderId);
|
||||
|
||||
EarningsLineEntity line = new EarningsLineEntity();
|
||||
String earningId = "earn-deduct-auth-" + IdUtils.getUuid();
|
||||
line.setId(earningId);
|
||||
line.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
line.setClerkId(clerkId);
|
||||
line.setOrderId(orderId);
|
||||
line.setSourceType(EarningsSourceType.ORDER);
|
||||
line.setSourceId(orderId);
|
||||
line.setAmount(amount);
|
||||
line.setEarningType(EarningsType.ORDER);
|
||||
line.setStatus("withdrawn");
|
||||
line.setUnlockTime(LocalDateTime.now().minusHours(1));
|
||||
line.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
line.setCreatedTime(nowDate);
|
||||
line.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
line.setUpdatedTime(nowDate);
|
||||
line.setDeleted(false);
|
||||
earningsService.save(line);
|
||||
earningsIdsToCleanup.add(earningId);
|
||||
}
|
||||
|
||||
private String seedClerkInOtherGroup(String groupId, String leaderUserId, String groupName, String groupLeaderSysUserId) {
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId(groupLeaderSysUserId);
|
||||
group.setSysUserCode(leaderUserId);
|
||||
group.setGroupName(groupName);
|
||||
group.setLeaderName(leaderUserId);
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-deduct-auth-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-" + clerkId);
|
||||
clerk.setNickname("Auth Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
return clerkId;
|
||||
}
|
||||
|
||||
private String otherLeaderId() {
|
||||
return "leader-scope-" + IdUtils.getUuid();
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime time) {
|
||||
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
private void cleanupBatches(List<String> batchIds) {
|
||||
if (batchIds == null || batchIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!tableExists("play_earnings_deduction_batch")) {
|
||||
return;
|
||||
}
|
||||
for (String batchId : batchIds) {
|
||||
cleanupBatch(batchId);
|
||||
}
|
||||
batchIds.clear();
|
||||
}
|
||||
|
||||
private void cleanupBatch(String batchId) {
|
||||
if (batchId == null || batchId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> adjustmentIds = new ArrayList<>();
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
adjustmentIds = jdbcTemplate.queryForList(
|
||||
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
|
||||
String.class,
|
||||
batchId);
|
||||
}
|
||||
if (!adjustmentIds.isEmpty()) {
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
|
||||
if (tableExists("play_earnings_deduction_batch_log")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
|
||||
}
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
|
||||
}
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void seedBatchItemAndLogForOtherClerk(String batchId, String clerkId) {
|
||||
Date now = new Date();
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_batch " +
|
||||
"(id, tenant_id, status, begin_time, end_time, rule_type, rule_value, reason_description, idempotency_key, request_hash, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
"FAILED",
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now().minusDays(7)),
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now()),
|
||||
"FIXED",
|
||||
"10.00",
|
||||
"seed",
|
||||
"seed-" + batchId,
|
||||
"seed",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_item " +
|
||||
"(id, batch_id, tenant_id, clerk_id, base_amount, apply_amount, status, adjustment_id, failure_reason, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"item-" + IdUtils.getUuid(),
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
clerkId,
|
||||
"100.00",
|
||||
"-10.00",
|
||||
"FAILED",
|
||||
null,
|
||||
"seed failure",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_batch_log " +
|
||||
"(id, batch_id, tenant_id, event_type, status_from, status_to, message, payload, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"log-" + IdUtils.getUuid(),
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
"ITEM_FAILED",
|
||||
"PROCESSING",
|
||||
"FAILED",
|
||||
"seed item failed",
|
||||
"{\"clerkId\":\"" + clerkId + "\"}",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* End-to-end contract tests for admin batch deductions (bonus/punishment across clerks).
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the batch deduction system is implemented end-to-end.</p>
|
||||
*/
|
||||
class AdminEarningsDeductionBatchControllerApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:deduction:create,withdraw:deduction:read";
|
||||
|
||||
private static final String BASE_URL = "/admin/earnings/deductions";
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> ordersToCleanup = new ArrayList<>();
|
||||
private final List<String> earningsToCleanup = new ArrayList<>();
|
||||
private final List<String> batchIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
ordersToCleanup.clear();
|
||||
earningsToCleanup.clear();
|
||||
batchIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupBatches(batchIdsToCleanup);
|
||||
if (!earningsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningsToCleanup);
|
||||
}
|
||||
if (!ordersToCleanup.isEmpty()) {
|
||||
orderInfoService.removeByIds(ordersToCleanup);
|
||||
}
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewRequiresRequiredFieldsReturns400() throws Exception {
|
||||
mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewPercentageUsesOnlyOrderLinesPositiveAmountsAndOrderEndTimeWindow() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
String clerkId = ensureTestClerkInDefaultTenant();
|
||||
|
||||
// In window: order1 + order2
|
||||
String order1 = seedOrder(clerkId, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(clerkId, order1, new BigDecimal("100.00"), "withdrawn", EarningsType.ORDER);
|
||||
seedOrderEarningLine(clerkId, order1, new BigDecimal("-30.00"), "available", EarningsType.ADJUSTMENT);
|
||||
|
||||
String order2 = seedOrder(clerkId, now.minusDays(2), new BigDecimal("50.00"));
|
||||
seedOrderEarningLine(clerkId, order2, new BigDecimal("50.00"), "available", EarningsType.ORDER);
|
||||
|
||||
// Out of window: order3 should not contribute
|
||||
String order3 = seedOrder(clerkId, now.minusDays(30), new BigDecimal("999.00"));
|
||||
seedOrderEarningLine(clerkId, order3, new BigDecimal("999.00"), "withdrawn", EarningsType.ORDER);
|
||||
|
||||
// Adjustment line (order_id=null) should not contribute even if positive
|
||||
seedAdjustmentEarningLine(clerkId, new BigDecimal("1000.00"), "available");
|
||||
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + clerkId + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"PERCENTAGE\"," +
|
||||
"\"percentage\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"week bonus\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.items").isArray())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
JsonNode item = root.path("data").path("items").get(0);
|
||||
assertThat(item.path("clerkId").asText()).isEqualTo(clerkId);
|
||||
|
||||
BigDecimal baseAmount = new BigDecimal(item.path("baseAmount").asText("0"));
|
||||
BigDecimal applyAmount = new BigDecimal(item.path("applyAmount").asText("0"));
|
||||
assertThat(baseAmount).isEqualByComparingTo("150.00"); // 100 + 50 (negative & non-order excluded)
|
||||
assertThat(applyAmount).isEqualByComparingTo("15.00"); // 10% bonus
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewWindowIsInclusiveOnBoundaries() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.minusDays(1);
|
||||
String clerkId = ensureTestClerkInDefaultTenant();
|
||||
|
||||
String orderBegin = seedOrder(clerkId, begin, new BigDecimal("20.00"));
|
||||
seedOrderEarningLine(clerkId, orderBegin, new BigDecimal("20.00"), "withdrawn", EarningsType.ORDER);
|
||||
|
||||
String orderEnd = seedOrder(clerkId, end, new BigDecimal("30.00"));
|
||||
seedOrderEarningLine(clerkId, orderEnd, new BigDecimal("30.00"), "withdrawn", EarningsType.ORDER);
|
||||
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + clerkId + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"PERCENTAGE\"," +
|
||||
"\"percentage\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"boundary\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
JsonNode item = root.path("data").path("items").get(0);
|
||||
BigDecimal baseAmount = new BigDecimal(item.path("baseAmount").asText("0"));
|
||||
assertThat(baseAmount).isEqualByComparingTo("50.00"); // 20 + 30 (both boundary-included)
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewRejectsCrossTenantClerkScopeReturns403() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "withdrawn", EarningsType.ORDER);
|
||||
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"PERCENTAGE\"," +
|
||||
"\"percentage\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"tenant isolation\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReturns202AndProvidesPollingHandle() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"PERCENTAGE\"," +
|
||||
"\"percentage\":\"10.00\"," +
|
||||
"\"operation\":\"PUNISHMENT\"," +
|
||||
"\"reasonDescription\":\"week penalty\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(header().string("Location", BASE_URL + "/idempotency/" + key))
|
||||
.andExpect(jsonPath("$.code").value(202))
|
||||
.andExpect(jsonPath("$.data.batchId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.status").value("PROCESSING"))
|
||||
.andReturn();
|
||||
|
||||
String batchId = extractBatchId(result);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createIsIdempotentWithSameKeyAndSameBody() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"fixed bonus\"" +
|
||||
"}";
|
||||
|
||||
MvcResult first = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchA = extractBatchId(first);
|
||||
|
||||
MvcResult second = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchB = extractBatchId(second);
|
||||
|
||||
assertThat(batchB).isEqualTo(batchA);
|
||||
batchIdsToCleanup.add(batchA);
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createConcurrentRequestsSameKeyOnlyOneBatchCreated() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"concurrent\"" +
|
||||
"}";
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<String> call = () -> {
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
return extractBatchId(result);
|
||||
};
|
||||
List<Future<String>> futures = new ArrayList<>();
|
||||
futures.add(pool.submit(call));
|
||||
futures.add(pool.submit(call));
|
||||
|
||||
String a = futures.get(0).get();
|
||||
String b = futures.get(1).get();
|
||||
assertThat(a).isNotBlank();
|
||||
assertThat(b).isEqualTo(a);
|
||||
batchIdsToCleanup.add(a);
|
||||
awaitApplied(key);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSameKeyDifferentBodyReturns409() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payloadA = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"fixed bonus\"" +
|
||||
"}";
|
||||
String payloadB = payloadA.replace("\"50.00\"", "\"60.00\"");
|
||||
|
||||
MvcResult first = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadA))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(first);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadB))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollMissingIdempotencyKeyReturns404() throws Exception {
|
||||
mockMvc.perform(get(BASE_URL + "/idempotency/" + UUID.randomUUID())
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyIsTenantScoped() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"PUNISHMENT\"," +
|
||||
"\"reasonDescription\":\"tenant scope\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemsAfterAppliedHaveAdjustmentIdAndNoDuplicateEarningsLines() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"operation\":\"PUNISHMENT\"," +
|
||||
"\"reasonDescription\":\"fixed penalty\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
MvcResult items = mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data[0].adjustmentId").isNotEmpty())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(items.getResponse().getContentAsString());
|
||||
JsonNode first = root.path("data").get(0);
|
||||
String adjustmentId = first.path("adjustmentId").asText();
|
||||
assertThat(adjustmentId).isNotBlank();
|
||||
|
||||
long count = earningsService.lambdaQuery()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.eq(EarningsLineEntity::getSourceId, adjustmentId)
|
||||
.count();
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemsPaginationWorks() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String secondClerkId = ensureTestClerkInDefaultTenant();
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String order2 = seedOrder(secondClerkId, now.minusDays(1), new BigDecimal("80.00"));
|
||||
seedOrderEarningLine(secondClerkId, order2, new BigDecimal("80.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\",\"" + secondClerkId + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"pagination\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data.length()").value(1))
|
||||
.andExpect(jsonPath("$.total").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logsContainCreatedAndFinalEvents() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"audit log\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data[?(@.eventType=='CREATED')]").exists())
|
||||
.andExpect(jsonPath("$.data[?(@.eventType=='APPLY_STARTED')]").exists())
|
||||
.andExpect(jsonPath("$.data[?(@.eventType=='BATCH_APPLIED')]").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemsAndLogsAreTenantScopedToBatchTenant() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"tenant scope items\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isNotFound());
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logsRecordOperatorInCreatedBy() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"operator audit\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
awaitApplied(key);
|
||||
|
||||
MvcResult logs = mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(logs.getResponse().getContentAsString());
|
||||
JsonNode data = root.path("data");
|
||||
boolean hasOperator = false;
|
||||
for (JsonNode node : data) {
|
||||
String createdBy = node.path("createdBy").asText();
|
||||
if (ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID.equals(createdBy)) {
|
||||
hasOperator = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(hasOperator).isTrue();
|
||||
}
|
||||
|
||||
private String extractBatchId(MvcResult result) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("batchId").asText();
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 120; i++) {
|
||||
MvcResult poll = mockMvc.perform(get(BASE_URL + "/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("batchId").asText();
|
||||
}
|
||||
if ("FAILED".equals(status)) {
|
||||
throw new AssertionError("batch failed unexpectedly: key=" + idempotencyKey);
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("batch not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private String seedOrder(String clerkId, LocalDateTime endTime, BigDecimal estimatedRevenue) {
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
String id = "order-deduct-" + IdUtils.getUuid();
|
||||
order.setId(id);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setOrderNo("DED-" + System.currentTimeMillis());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderType("2");
|
||||
order.setPlaceType("0");
|
||||
order.setRewardType("0");
|
||||
order.setAcceptBy(clerkId);
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
order.setOrderMoney(new BigDecimal("120.50"));
|
||||
order.setFinalAmount(order.getOrderMoney());
|
||||
order.setEstimatedRevenue(estimatedRevenue);
|
||||
order.setOrderSettlementState("1");
|
||||
order.setOrderEndTime(endTime);
|
||||
order.setOrderSettlementTime(endTime);
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(nowDate);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(nowDate);
|
||||
order.setDeleted(false);
|
||||
orderInfoService.save(order);
|
||||
ordersToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void seedOrderEarningLine(String clerkId, String orderId, BigDecimal amount, String status, EarningsType earningType) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-deduct-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId(orderId);
|
||||
entity.setSourceType(EarningsSourceType.ORDER);
|
||||
entity.setSourceId(orderId);
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(earningType);
|
||||
entity.setStatus(status);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(nowDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(nowDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningsToCleanup.add(id);
|
||||
}
|
||||
|
||||
private void seedAdjustmentEarningLine(String clerkId, BigDecimal amount, String status) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-deduct-adj-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId(null);
|
||||
entity.setSourceType(EarningsSourceType.ADJUSTMENT);
|
||||
entity.setSourceId("adj-seed-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(EarningsType.ADJUSTMENT);
|
||||
entity.setStatus(status);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(nowDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(nowDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningsToCleanup.add(id);
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime time) {
|
||||
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
private void cleanupBatches(List<String> batchIds) {
|
||||
if (batchIds == null || batchIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!tableExists("play_earnings_deduction_batch")) {
|
||||
return;
|
||||
}
|
||||
for (String batchId : batchIds) {
|
||||
cleanupBatch(batchId);
|
||||
}
|
||||
batchIds.clear();
|
||||
}
|
||||
|
||||
private void cleanupBatch(String batchId) {
|
||||
if (batchId == null || batchId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> adjustmentIds = new ArrayList<>();
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
adjustmentIds = jdbcTemplate.queryForList(
|
||||
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
|
||||
String.class,
|
||||
batchId);
|
||||
}
|
||||
|
||||
if (!adjustmentIds.isEmpty()) {
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
|
||||
if (tableExists("play_earnings_deduction_batch_log")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
|
||||
}
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
|
||||
}
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String ensureTestClerkInDefaultTenant() {
|
||||
String clerkId = "clerk-deduct-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-" + clerkId);
|
||||
clerk.setNickname("Batch Clerk");
|
||||
clerk.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
return clerkId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
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.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* End-to-end retry/idempotency contract tests for batch deductions.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until retry semantics are implemented.</p>
|
||||
*/
|
||||
class AdminEarningsDeductionBatchRetryApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:deduction:create,withdraw:deduction:read";
|
||||
|
||||
private static final String BASE_URL = "/admin/earnings/deductions";
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> ordersToCleanup = new ArrayList<>();
|
||||
private final List<String> earningsToCleanup = new ArrayList<>();
|
||||
private final List<String> adjustmentsToCleanup = new ArrayList<>();
|
||||
private final List<String> batchIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
ordersToCleanup.clear();
|
||||
earningsToCleanup.clear();
|
||||
adjustmentsToCleanup.clear();
|
||||
batchIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupBatches(batchIdsToCleanup);
|
||||
if (!earningsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningsToCleanup);
|
||||
}
|
||||
if (!ordersToCleanup.isEmpty()) {
|
||||
orderInfoService.removeByIds(ordersToCleanup);
|
||||
}
|
||||
if (!adjustmentsToCleanup.isEmpty()) {
|
||||
adjustmentService.removeByIds(adjustmentsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryNoopWhenAllItemsAppliedDoesNotCreateDuplicateLines() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"retry noop\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
awaitApplied(key);
|
||||
|
||||
String adjustmentId = fetchFirstItemAdjustmentId(batchId);
|
||||
long before = countEarningsLinesForAdjustment(adjustmentId);
|
||||
|
||||
mockMvc.perform(post(BASE_URL + "/" + batchId + "/retry")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
long after = countEarningsLinesForAdjustment(adjustmentId);
|
||||
assertThat(after).isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryCanRecoverFailedItemByReapplyingSameAdjustmentId() throws Exception {
|
||||
// Seed a FAILED adjustment + a FAILED batch item referencing that adjustment.
|
||||
// Contract: retry should reset FAILED adjustments/items and re-apply, without creating duplicates.
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
|
||||
|
||||
String batchId = "batch-retry-" + IdUtils.getUuid();
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
EarningsLineAdjustmentEntity adjustment = new EarningsLineAdjustmentEntity();
|
||||
String adjustmentId = "adj-retry-" + IdUtils.getUuid();
|
||||
adjustment.setId(adjustmentId);
|
||||
adjustment.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
adjustment.setClerkId(clerkId);
|
||||
adjustment.setAmount(new BigDecimal("-10.00"));
|
||||
adjustment.setReasonType(EarningsAdjustmentReasonType.MANUAL);
|
||||
adjustment.setReasonDescription("seed failed adjustment");
|
||||
adjustment.setStatus(EarningsAdjustmentStatus.FAILED);
|
||||
adjustment.setIdempotencyKey("deduct:" + batchId + ":" + clerkId);
|
||||
adjustment.setRequestHash("seed");
|
||||
adjustment.setEffectiveTime(now);
|
||||
adjustmentService.save(adjustment);
|
||||
adjustmentsToCleanup.add(adjustmentId);
|
||||
|
||||
seedBatchAndItemFailed(batchId, clerkId, new BigDecimal("100.00"), new BigDecimal("-10.00"), adjustmentId);
|
||||
|
||||
mockMvc.perform(post(BASE_URL + "/" + batchId + "/retry")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
awaitBatchApplied(batchId);
|
||||
|
||||
EarningsLineAdjustmentEntity refreshed = adjustmentService.getById(adjustmentId);
|
||||
assertThat(refreshed).isNotNull();
|
||||
assertThat(refreshed.getStatus()).isEqualTo(EarningsAdjustmentStatus.APPLIED);
|
||||
|
||||
assertThat(countEarningsLinesForAdjustment(adjustmentId)).isEqualTo(1);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data[?(@.eventType=='RETRY_STARTED')]").exists());
|
||||
}
|
||||
|
||||
private String extractBatchId(MvcResult result) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("batchId").asText();
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 120; i++) {
|
||||
MvcResult poll = mockMvc.perform(get(BASE_URL + "/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("batchId").asText();
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("batch not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private void awaitBatchApplied(String batchId) throws Exception {
|
||||
for (int i = 0; i < 120; i++) {
|
||||
MvcResult poll = mockMvc.perform(get(BASE_URL + "/" + batchId)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("batch not applied within timeout: batchId=" + batchId);
|
||||
}
|
||||
|
||||
private String fetchFirstItemAdjustmentId(String batchId) throws Exception {
|
||||
MvcResult items = mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(items.getResponse().getContentAsString());
|
||||
return root.path("data").get(0).path("adjustmentId").asText();
|
||||
}
|
||||
|
||||
private long countEarningsLinesForAdjustment(String adjustmentId) {
|
||||
if (adjustmentId == null || adjustmentId.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return earningsService.lambdaQuery()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.eq(EarningsLineEntity::getSourceId, adjustmentId)
|
||||
.count();
|
||||
}
|
||||
|
||||
private void seedBatchAndItemFailed(String batchId, String clerkId, BigDecimal baseAmount, BigDecimal applyAmount, String adjustmentId) {
|
||||
Date now = new Date();
|
||||
// Batch
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_batch " +
|
||||
"(id, tenant_id, status, begin_time, end_time, rule_type, rule_value, reason_description, idempotency_key, request_hash, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
"FAILED",
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now().minusDays(7)),
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now()),
|
||||
"FIXED",
|
||||
applyAmount.abs().setScale(2).toPlainString(),
|
||||
"seed batch",
|
||||
"seed-" + batchId,
|
||||
"seed",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
|
||||
// Item
|
||||
String itemId = "item-retry-" + IdUtils.getUuid();
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_item " +
|
||||
"(id, batch_id, tenant_id, clerk_id, base_amount, apply_amount, status, adjustment_id, failure_reason, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
itemId,
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
clerkId,
|
||||
baseAmount.setScale(2).toPlainString(),
|
||||
applyAmount.setScale(2).toPlainString(),
|
||||
"FAILED",
|
||||
adjustmentId,
|
||||
"seed failure",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
}
|
||||
|
||||
private String seedOrder(String clerkId, LocalDateTime endTime, BigDecimal estimatedRevenue) {
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
String id = "order-deduct-retry-" + IdUtils.getUuid();
|
||||
order.setId(id);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setOrderNo("RET-" + System.currentTimeMillis());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderType("2");
|
||||
order.setPlaceType("0");
|
||||
order.setRewardType("0");
|
||||
order.setAcceptBy(clerkId);
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
order.setOrderMoney(new BigDecimal("120.50"));
|
||||
order.setFinalAmount(order.getOrderMoney());
|
||||
order.setEstimatedRevenue(estimatedRevenue);
|
||||
order.setOrderSettlementState("1");
|
||||
order.setOrderEndTime(endTime);
|
||||
order.setOrderSettlementTime(endTime);
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(nowDate);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(nowDate);
|
||||
order.setDeleted(false);
|
||||
orderInfoService.save(order);
|
||||
ordersToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void seedOrderEarningLine(String clerkId, String orderId, BigDecimal amount, String status, EarningsType earningType) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-deduct-retry-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId(orderId);
|
||||
entity.setSourceType(EarningsSourceType.ORDER);
|
||||
entity.setSourceId(orderId);
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(earningType);
|
||||
entity.setStatus(status);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(nowDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(nowDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningsToCleanup.add(id);
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime time) {
|
||||
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
private void cleanupBatches(List<String> batchIds) {
|
||||
if (batchIds == null || batchIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!tableExists("play_earnings_deduction_batch")) {
|
||||
return;
|
||||
}
|
||||
for (String batchId : batchIds) {
|
||||
cleanupBatch(batchId);
|
||||
}
|
||||
batchIds.clear();
|
||||
}
|
||||
|
||||
private void cleanupBatch(String batchId) {
|
||||
if (batchId == null || batchId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> adjustmentIds = new ArrayList<>();
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
adjustmentIds = jdbcTemplate.queryForList(
|
||||
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
|
||||
String.class,
|
||||
batchId);
|
||||
}
|
||||
if (!adjustmentIds.isEmpty()) {
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
|
||||
if (tableExists("play_earnings_deduction_batch_log")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
|
||||
}
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
|
||||
}
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD tests: withdrawal audit must tolerate adjustment lines (orderId=null) and still return a stable payload.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until earnings lines support orderId=null + sourceType/sourceId,
|
||||
* and audit serialization handles mixed sources.</p>
|
||||
*/
|
||||
class AdminWithdrawalAuditWithAdjustmentsApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String withdrawalId;
|
||||
private String orderId;
|
||||
private String orderLineId;
|
||||
private String adjustmentLineId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
List<String> toDelete = new ArrayList<>();
|
||||
if (orderLineId != null) {
|
||||
toDelete.add(orderLineId);
|
||||
}
|
||||
if (adjustmentLineId != null) {
|
||||
toDelete.add(adjustmentLineId);
|
||||
}
|
||||
if (!toDelete.isEmpty()) {
|
||||
earningsService.removeByIds(toDelete);
|
||||
}
|
||||
if (withdrawalId != null) {
|
||||
withdrawalService.removeById(withdrawalId);
|
||||
}
|
||||
if (orderId != null) {
|
||||
orderInfoService.removeById(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditReturnsOrderDetailsForOrderLinesAndLeavesOrderFieldsEmptyForAdjustments() throws Exception {
|
||||
seedOrderWithdrawalAndLines();
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/admin/withdraw/requests/" + withdrawalId + "/audit")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andReturn();
|
||||
|
||||
JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data");
|
||||
JsonNode first = data.get(0);
|
||||
JsonNode second = data.get(1);
|
||||
|
||||
// First entry is ORDER: should have orderNo present.
|
||||
boolean firstIsOrder = "ORDER".equals(first.path("earningType").asText());
|
||||
JsonNode orderNode = firstIsOrder ? first : second;
|
||||
JsonNode adjustmentNode = firstIsOrder ? second : first;
|
||||
|
||||
assertThat(orderNode.path("earningType").asText()).isEqualTo("ORDER");
|
||||
assertThat(orderNode.path("orderNo").asText()).isNotBlank();
|
||||
|
||||
assertThat(adjustmentNode.path("earningType").asText()).isEqualTo("ADJUSTMENT");
|
||||
assertThat(adjustmentNode.path("orderId").isMissingNode() || adjustmentNode.path("orderId").isNull()).isTrue();
|
||||
assertThat(adjustmentNode.path("orderNo").isMissingNode() || adjustmentNode.path("orderNo").isNull()).isTrue();
|
||||
}
|
||||
|
||||
private void seedOrderWithdrawalAndLines() {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
|
||||
// order
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
orderId = "order-audit-adj-" + IdUtils.getUuid();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
order.setOrderNo("audit-adj-" + IdUtils.getUuid());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderEndTime(now.minusHours(1));
|
||||
order.setFinalAmount(new BigDecimal("120.00"));
|
||||
order.setEstimatedRevenue(new BigDecimal("60.00"));
|
||||
order.setDeleted(false);
|
||||
Date dt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(dt);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(dt);
|
||||
orderInfoService.save(order);
|
||||
|
||||
// withdrawal request
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-audit-adj-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("88.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:audit-adj@test.com");
|
||||
req.setStatus("processing");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"审计专用\"}");
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(dt);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(dt);
|
||||
withdrawalService.save(req);
|
||||
|
||||
// order line
|
||||
orderLineId = seedEarningLine(withdrawalId, orderId, new BigDecimal("60.00"), "withdrawn", EarningsType.ORDER, now.minusMinutes(30));
|
||||
|
||||
// adjustment line (intended future: orderId=null + sourceType/sourceId)
|
||||
adjustmentLineId = seedEarningLine(withdrawalId, null, new BigDecimal("-10.00"), "withdrawing", EarningsType.ADJUSTMENT, now.minusMinutes(10));
|
||||
}
|
||||
|
||||
private String seedEarningLine(String withdrawalId, String orderIdOrNull, BigDecimal amount, String status, EarningsType type, LocalDateTime createdAt) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-audit-adj-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId(orderIdOrNull);
|
||||
entity.setAmount(amount);
|
||||
entity.setStatus(status);
|
||||
entity.setEarningType(type);
|
||||
entity.setUnlockTime(createdAt.minusHours(1));
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
Date createdDate = Date.from(createdAt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(createdDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(createdDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
/**
|
||||
* TDD contract tests for rejecting/canceling a withdrawal request (release reserved earnings lines).
|
||||
*
|
||||
* <p>These tests are expected to FAIL until withdrawal reject is implemented.</p>
|
||||
*/
|
||||
class AdminWithdrawalRejectApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
private String withdrawalId;
|
||||
private String lineA;
|
||||
private String lineB;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
withdrawalId = null;
|
||||
lineA = null;
|
||||
lineB = null;
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (withdrawalId != null) {
|
||||
withdrawalService.removeById(withdrawalId);
|
||||
}
|
||||
List<String> toDelete = new ArrayList<>();
|
||||
if (lineA != null) {
|
||||
toDelete.add(lineA);
|
||||
}
|
||||
if (lineB != null) {
|
||||
toDelete.add(lineB);
|
||||
}
|
||||
if (!toDelete.isEmpty()) {
|
||||
earningsService.removeByIds(toDelete);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectPendingWithdrawalReleasesWithdrawingLines() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
|
||||
String payload = "{\"reason\":\"bank account mismatch\"}";
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
EarningsLineEntity afterA = earningsService.getById(lineA);
|
||||
EarningsLineEntity afterB = earningsService.getById(lineB);
|
||||
assertThat(afterA.getWithdrawalId()).isNull();
|
||||
assertThat(afterB.getWithdrawalId()).isNull();
|
||||
assertThat(afterA.getStatus()).isIn("available", "frozen");
|
||||
assertThat(afterB.getStatus()).isIn("available", "frozen");
|
||||
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(withdrawalId);
|
||||
assertThat(req.getStatus()).isIn("canceled", "rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectIsIdempotent() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
|
||||
String payload = "{\"reason\":\"duplicate\"}";
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectRestoresFrozenWhenUnlockTimeInFuture() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-reject-frozen-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("10.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
|
||||
lineA = seedLineWithUnlock("reject-future", new BigDecimal("10.00"), LocalDateTime.now().plusDays(1));
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"future unlock\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
EarningsLineEntity after = earningsService.getById(lineA);
|
||||
assertThat(after.getStatus()).isEqualTo("frozen");
|
||||
assertThat(after.getWithdrawalId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectSuccessWithdrawalIsRejected() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(withdrawalId);
|
||||
req.setStatus("success");
|
||||
withdrawalService.updateById(req);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"cannot reject success\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
private void seedWithdrawingWithdrawalAndLines() {
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-reject-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("80.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
|
||||
lineA = seedLine("reject-a", new BigDecimal("50.00"));
|
||||
lineB = seedLine("reject-b", new BigDecimal("30.00"));
|
||||
}
|
||||
|
||||
private String seedLine(String suffix, BigDecimal amount) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId("order-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusDays(1));
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
|
||||
private String seedLineWithUnlock(String suffix, BigDecimal amount, LocalDateTime unlockAt) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId("order-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(unlockAt);
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
/**
|
||||
* Authorization contract tests for rejecting withdrawals.
|
||||
*
|
||||
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
|
||||
*/
|
||||
class AdminWithdrawalRejectAuthorizationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService groupInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
private final List<String> withdrawalIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> earningLineIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> groupIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
withdrawalIdsToCleanup.clear();
|
||||
earningLineIdsToCleanup.clear();
|
||||
clerkIdsToCleanup.clear();
|
||||
groupIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!withdrawalIdsToCleanup.isEmpty()) {
|
||||
withdrawalService.removeByIds(withdrawalIdsToCleanup);
|
||||
}
|
||||
if (!earningLineIdsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningLineIdsToCleanup);
|
||||
}
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
if (!groupIdsToCleanup.isEmpty()) {
|
||||
groupInfoService.removeByIds(groupIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectWithoutPermissionReturns403() throws Exception {
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectLeaderWithPermissionCannotManageOtherGroupReturns403() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectSuperAdminBypassesPermissionAndScopeReturns200() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid())
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(SUPER_ADMIN_HEADER, "true")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossTenantRejectReturns404EvenWithPermission() throws Exception {
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String seedWithdrawingWithdrawalAndLines(String clerkId) {
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
String withdrawalId = "withdraw-auth-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(clerkId);
|
||||
req.setAmount(new BigDecimal("80.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
withdrawalIdsToCleanup.add(withdrawalId);
|
||||
|
||||
earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "a", new BigDecimal("50.00")));
|
||||
earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "b", new BigDecimal("30.00")));
|
||||
|
||||
return withdrawalId;
|
||||
}
|
||||
|
||||
private String seedLine(String withdrawalId, String clerkId, String suffix, BigDecimal amount) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-auth-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId("order-auth-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusDays(1));
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
|
||||
private String seedOtherGroupClerk() {
|
||||
String groupId = "group-auth-withdraw-" + IdUtils.getUuid();
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId("leader-auth-" + IdUtils.getUuid());
|
||||
group.setSysUserCode("leader-auth");
|
||||
group.setGroupName("Auth Test Group");
|
||||
group.setLeaderName("Leader Auth");
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-auth-withdraw-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-auth-" + clerkId);
|
||||
clerk.setNickname("Auth Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("138" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-auth-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
|
||||
return clerkId;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public class MockWxMpServiceConfig {
|
||||
WxMpService service = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
|
||||
WxMpTemplateMsgService templateMsgService = mock(WxMpTemplateMsgService.class);
|
||||
when(service.getTemplateMsgService()).thenReturn(templateMsgService);
|
||||
when(service.switchoverTo(Mockito.anyString())).thenReturn(service);
|
||||
when(service.switchoverTo(Mockito.nullable(String.class))).thenReturn(service);
|
||||
when(service.switchover(Mockito.anyString())).thenReturn(true);
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.common.domain.LoginUser;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD contract tests for statistics toggle: include/exclude earnings adjustments.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the stats endpoint supports includeAdjustments.</p>
|
||||
*/
|
||||
class StatisticsPerformanceOverviewIncludeAdjustmentsApiTest extends AbstractApiTest {
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final LocalDateTime BASE_TIME = LocalDateTime.of(2011, 1, 1, 12, 0, 0);
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private EarningsLineMapper earningsLineMapper;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String orderId;
|
||||
private final List<String> earningLineIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
orderId = null;
|
||||
earningLineIdsToCleanup.clear();
|
||||
setAuthentication();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!earningLineIdsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningLineIdsToCleanup);
|
||||
}
|
||||
if (orderId != null) {
|
||||
orderInfoService.removeById(orderId);
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewExcludesAdjustmentsByDefaultAndIncludesWhenToggledOn() throws Exception {
|
||||
seedOneOrderAndLines();
|
||||
|
||||
LocalDateTime start = BASE_TIME.minusMinutes(5);
|
||||
LocalDateTime end = BASE_TIME.plusMinutes(5);
|
||||
|
||||
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
ApiTestDataSeeder.DEFAULT_CLERK_ID,
|
||||
start.format(DATE_TIME_FORMATTER),
|
||||
end.format(DATE_TIME_FORMATTER));
|
||||
assertThat(adjustmentSum.setScale(2, RoundingMode.HALF_UP)).isEqualByComparingTo(new BigDecimal("-20.00"));
|
||||
|
||||
String payloadDefault = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult defaultResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadDefault))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode defaultJson = objectMapper.readTree(defaultResult.getResponse().getContentAsString());
|
||||
BigDecimal defaultRevenue = new BigDecimal(defaultJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(defaultRevenue).isEqualByComparingTo(new BigDecimal("100.00"));
|
||||
|
||||
String payloadInclude = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"includeAdjustments\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult includeResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadInclude))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode includeJson = objectMapper.readTree(includeResult.getResponse().getContentAsString());
|
||||
BigDecimal includeRevenue = new BigDecimal(includeJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(includeRevenue).isEqualByComparingTo(new BigDecimal("80.00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void includeAdjustmentsRespectsTimeWindow() throws Exception {
|
||||
seedOneOrderAndLines();
|
||||
|
||||
// Seed another adjustment intended to be outside the window.
|
||||
// Intended future behavior: adjustment is filtered by its effectiveTime/createdTime within the same window.
|
||||
seedOutOfWindowAdjustmentLine();
|
||||
|
||||
LocalDateTime start = BASE_TIME.minusMinutes(5);
|
||||
LocalDateTime end = BASE_TIME.plusMinutes(5);
|
||||
|
||||
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
ApiTestDataSeeder.DEFAULT_CLERK_ID,
|
||||
start.format(DATE_TIME_FORMATTER),
|
||||
end.format(DATE_TIME_FORMATTER));
|
||||
assertThat(adjustmentSum.setScale(2, RoundingMode.HALF_UP)).isEqualByComparingTo(new BigDecimal("-20.00"));
|
||||
|
||||
String payloadInclude = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"includeAdjustments\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult includeResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadInclude))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode includeJson = objectMapper.readTree(includeResult.getResponse().getContentAsString());
|
||||
BigDecimal includeRevenue = new BigDecimal(includeJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
// Base is 100.00 order revenue, -20.00 in-window adjustment; the out-of-window adjustment should NOT be counted.
|
||||
assertThat(includeRevenue).isEqualByComparingTo(new BigDecimal("80.00"));
|
||||
}
|
||||
|
||||
private void seedOneOrderAndLines() {
|
||||
LocalDateTime now = BASE_TIME.withNano(0);
|
||||
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
orderId = "order-stats-" + IdUtils.getUuid();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
order.setOrderNo("stats-" + IdUtils.getUuid());
|
||||
order.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode());
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setPurchaserTime(now.minusMinutes(1));
|
||||
order.setPlaceType(OrderConstant.PlaceType.REWARD.getCode());
|
||||
order.setRefundType(OrderConstant.OrderRefundFlag.NOT_REFUNDED.getCode());
|
||||
order.setOrdersExpiredState(OrderConstant.OrdersExpiredState.NOT_EXPIRED.getCode());
|
||||
order.setOrderRelationType(OrderConstant.OrderRelationType.FIRST);
|
||||
order.setFinalAmount(new BigDecimal("200.00"));
|
||||
order.setEstimatedRevenue(new BigDecimal("100.00"));
|
||||
order.setOrderEndTime(now);
|
||||
order.setDeleted(false);
|
||||
Date created = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(created);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(created);
|
||||
orderInfoService.save(order);
|
||||
|
||||
seedEarningsLine("earn-order", orderId, new BigDecimal("100.00"), EarningsType.ORDER, now.minusMinutes(1));
|
||||
|
||||
// Intended future behavior: adjustment lines are not ORDER-sourced and should only affect stats when includeAdjustments=true.
|
||||
// This will FAIL until play_earnings_line supports orderId=null + sourceType/sourceId, and stats toggle is implemented.
|
||||
seedEarningsLine("earn-adjustment", null, new BigDecimal("-20.00"), EarningsType.ADJUSTMENT, now.minusMinutes(1));
|
||||
}
|
||||
|
||||
private String seedEarningsLine(
|
||||
String prefix, String orderIdOrNull, BigDecimal amount, EarningsType type, LocalDateTime unlockTime) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = prefix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId(orderIdOrNull);
|
||||
if (orderIdOrNull == null) {
|
||||
entity.setSourceType(EarningsSourceType.ADJUSTMENT);
|
||||
entity.setSourceId("adj-stats-" + IdUtils.getUuid());
|
||||
} else {
|
||||
entity.setSourceType(EarningsSourceType.ORDER);
|
||||
entity.setSourceId(orderIdOrNull);
|
||||
}
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(type);
|
||||
entity.setStatus("available");
|
||||
entity.setUnlockTime(unlockTime);
|
||||
Date created = Date.from(BASE_TIME.atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(created);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(created);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningLineIdsToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void seedOutOfWindowAdjustmentLine() {
|
||||
seedEarningsLine(
|
||||
"earn-adjustment-outside",
|
||||
null,
|
||||
new BigDecimal("999.00"),
|
||||
EarningsType.ADJUSTMENT,
|
||||
BASE_TIME.minusDays(30));
|
||||
}
|
||||
|
||||
private void setAuthentication() {
|
||||
SysUserEntity user = new SysUserEntity();
|
||||
user.setUserId(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
user.setUserCode(ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME);
|
||||
user.setPassWord("apitest");
|
||||
user.setStatus(0);
|
||||
user.setSuperAdmin(true);
|
||||
user.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUserId(user.getUserId());
|
||||
loginUser.setUserName(user.getUserCode());
|
||||
loginUser.setUser(user);
|
||||
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, java.util.Collections.emptyList()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkArticleInfoEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayCustomArticleInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkArticleInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayCustomArticleInfoService;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
class WxArticleControllerApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkArticleInfoService clerkArticleInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomArticleInfoService customArticleInfoService;
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomUserInfoService customUserInfoService;
|
||||
|
||||
private final java.util.List<String> createdArticleIds = new java.util.ArrayList<>();
|
||||
private final java.util.List<String> createdCustomLinkIds = new java.util.ArrayList<>();
|
||||
private String clerkToken;
|
||||
private String customerToken;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
|
||||
customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
|
||||
|
||||
// Article create() blocks when there is any unaudited record for the clerk; keep tests deterministic.
|
||||
clerkArticleInfoService.lambdaUpdate()
|
||||
.eq(PlayClerkArticleInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(PlayClerkArticleInfoEntity::getReviewState, "0")
|
||||
.remove();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!createdCustomLinkIds.isEmpty()) {
|
||||
customArticleInfoService.removeByIds(createdCustomLinkIds);
|
||||
createdCustomLinkIds.clear();
|
||||
}
|
||||
if (!createdArticleIds.isEmpty()) {
|
||||
clerkArticleInfoService.removeByIds(createdArticleIds);
|
||||
createdArticleIds.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkAddCreatesArticleWithSessionClerkId__covers_ART_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String marker = "apitest-article-" + IdUtils.getUuid();
|
||||
|
||||
String payload = "{" +
|
||||
"\"articleCon\":\"" + marker + "\"," +
|
||||
"\"annexType\":\"0\"," +
|
||||
"\"annexCon\":[\"https://example.com/a.png\"]" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/wx/article/clerk/add")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.message").value("操作成功"));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayClerkArticleInfoEntity created = clerkArticleInfoService.lambdaQuery()
|
||||
.eq(PlayClerkArticleInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(PlayClerkArticleInfoEntity::getArticleCon, marker)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
if (created == null) {
|
||||
throw new AssertionError("Expected article row to be created");
|
||||
}
|
||||
createdArticleIds.add(created.getId());
|
||||
if (!ApiTestDataSeeder.DEFAULT_CLERK_ID.equals(created.getClerkId())) {
|
||||
throw new AssertionError("Expected clerkId to be set from session");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkDeleteByIdRemovesArticleAndCustomLinks__covers_ART_002() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String articleId = insertApprovedArticleForClerk();
|
||||
|
||||
PlayCustomArticleInfoEntity link = new PlayCustomArticleInfoEntity();
|
||||
link.setId("apitest-link-" + IdUtils.getUuid());
|
||||
link.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
link.setArticleId(articleId);
|
||||
link.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
link.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
link.setEndorseType("1");
|
||||
link.setEndorseState("1");
|
||||
link.setEndorseTime(LocalDateTime.now());
|
||||
customArticleInfoService.save(link);
|
||||
createdCustomLinkIds.add(link.getId());
|
||||
|
||||
mockMvc.perform(get("/wx/article/clerk/deleteById")
|
||||
.param("id", articleId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.message").value("操作成功"));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (clerkArticleInfoService.getById(articleId) != null) {
|
||||
throw new AssertionError("Expected article to be deleted");
|
||||
}
|
||||
long remainingLinks = customArticleInfoService.lambdaQuery()
|
||||
.eq(PlayCustomArticleInfoEntity::getArticleId, articleId)
|
||||
.count();
|
||||
if (remainingLinks != 0) {
|
||||
throw new AssertionError("Expected custom article links to be deleted");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void customUpdateGreedStateUpsertsAndUpdatesEndorseTime__covers_ART_003() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String articleId = insertApprovedArticleForClerk();
|
||||
|
||||
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
|
||||
|
||||
mockMvc.perform(post("/wx/article/custom/updateGreedState")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"id\":\"" + articleId + "\",\"greedState\":\"1\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
java.util.List<PlayCustomArticleInfoEntity> likedRows = customArticleInfoService.lambdaQuery()
|
||||
.eq(PlayCustomArticleInfoEntity::getArticleId, articleId)
|
||||
.eq(PlayCustomArticleInfoEntity::getCustomId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
|
||||
.eq(PlayCustomArticleInfoEntity::getEndorseType, "1")
|
||||
.list();
|
||||
if (likedRows == null || likedRows.isEmpty()) {
|
||||
throw new AssertionError("Expected custom article greed record(s) to be created");
|
||||
}
|
||||
for (PlayCustomArticleInfoEntity item : likedRows) {
|
||||
createdCustomLinkIds.add(item.getId());
|
||||
}
|
||||
boolean anyLiked = likedRows.stream().anyMatch(item -> "1".equals(item.getEndorseState()));
|
||||
if (!anyLiked) {
|
||||
throw new AssertionError("Expected at least one greed record endorseState=1");
|
||||
}
|
||||
boolean anyRecent = likedRows.stream()
|
||||
.anyMatch(item -> item.getEndorseTime() != null && !item.getEndorseTime().isBefore(before));
|
||||
if (!anyRecent) {
|
||||
throw new AssertionError("Expected endorseTime to be set");
|
||||
}
|
||||
mockMvc.perform(post("/wx/article/custom/updateGreedState")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"id\":\"" + articleId + "\",\"greedState\":\"0\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
java.util.List<PlayCustomArticleInfoEntity> afterUnlikeRows = customArticleInfoService.lambdaQuery()
|
||||
.eq(PlayCustomArticleInfoEntity::getArticleId, articleId)
|
||||
.eq(PlayCustomArticleInfoEntity::getCustomId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
|
||||
.eq(PlayCustomArticleInfoEntity::getEndorseType, "1")
|
||||
.list();
|
||||
if (afterUnlikeRows == null || afterUnlikeRows.isEmpty()) {
|
||||
throw new AssertionError("Expected custom article greed record(s) to exist after update");
|
||||
}
|
||||
for (PlayCustomArticleInfoEntity item : afterUnlikeRows) {
|
||||
createdCustomLinkIds.add(item.getId());
|
||||
}
|
||||
boolean anyUnliked = afterUnlikeRows.stream().anyMatch(item -> "0".equals(item.getEndorseState()));
|
||||
if (!anyUnliked) {
|
||||
throw new AssertionError("Expected at least one greed record endorseState=0 after update");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void customUpdateFollowStateUpsertsAndUpdatesEndorseTime__covers_ART_004() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String articleId = insertApprovedArticleForClerk();
|
||||
|
||||
mockMvc.perform(post("/wx/article/custom/updateFollowState")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"id\":\"" + articleId + "\",\"followState\":\"1\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayCustomArticleInfoEntity row = customArticleInfoService.selectByArticleId(
|
||||
articleId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, "0");
|
||||
if (row == null) {
|
||||
throw new AssertionError("Expected follow record to be created");
|
||||
}
|
||||
createdCustomLinkIds.add(row.getId());
|
||||
if (!"0".equals(row.getEndorseType())) {
|
||||
throw new AssertionError("Expected endorseType=0 for follow");
|
||||
}
|
||||
if (!"1".equals(row.getEndorseState())) {
|
||||
throw new AssertionError("Expected endorseState=1");
|
||||
}
|
||||
if (row.getEndorseTime() == null) {
|
||||
throw new AssertionError("Expected endorseTime to be set");
|
||||
}
|
||||
}
|
||||
|
||||
private String insertApprovedArticleForClerk() {
|
||||
PlayClerkArticleInfoEntity entity = new PlayClerkArticleInfoEntity();
|
||||
String id = "apitest-clerk-article-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setArticleCon("seed");
|
||||
entity.setAnnexType("0");
|
||||
entity.setAnnexCon(Collections.singletonList("https://example.com/seed.png"));
|
||||
entity.setReviewState("1");
|
||||
entity.setReleaseTime(LocalDateTime.now());
|
||||
entity.setDeleted(Boolean.FALSE);
|
||||
clerkArticleInfoService.save(entity);
|
||||
createdArticleIds.add(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import me.chanjar.weixin.mp.api.WxMpService;
|
||||
import me.chanjar.weixin.mp.bean.result.WxMpUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
class WxAuthAspectApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomUserInfoService customUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private WxMpService wxMpService;
|
||||
|
||||
@Test
|
||||
void customUserLoginAspectRejectsMissingTokenHeader__covers_AOP_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/oauth2/checkSubscribe")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(401))
|
||||
.andExpect(jsonPath("$.message").value("token为空"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkUserLoginAspectRejectsMissingTokenHeader__covers_AOP_002() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/oauth2/clerk/logout")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(401))
|
||||
.andExpect(jsonPath("$.message").value("token为空"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customUserLoginAspectRejectsInvalidTokenFormat__covers_AOP_003() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/oauth2/checkSubscribe")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + "not-a-jwt"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(401))
|
||||
.andExpect(jsonPath("$.message").value("获取用户信息异常"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customUserLoginAspectRejectsTokenMismatchAgainstDatabase__covers_AOP_004() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
String userId = ApiTestDataSeeder.DEFAULT_CUSTOMER_ID;
|
||||
String token = wxTokenService.createWxUserToken(userId);
|
||||
customUserInfoService.updateTokenById(userId, "some-other-token");
|
||||
|
||||
mockMvc.perform(get("/wx/oauth2/checkSubscribe")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(401))
|
||||
.andExpect(jsonPath("$.message").value("token异常"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkUserLoginAspectRejectsOffboardedClerkAndInvalidatesSession__covers_AOP_005() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
String clerkId = "clerk-aop-offboarded";
|
||||
String openId = "openid-clerk-aop-offboarded";
|
||||
String token = wxTokenService.createWxUserToken(clerkId);
|
||||
|
||||
PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
|
||||
if (existing == null) {
|
||||
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
|
||||
entity.setId(clerkId);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setOpenid(openId);
|
||||
entity.setNickname("API Test Offboarded Clerk");
|
||||
entity.setAvatar("https://example.com/avatar.png");
|
||||
entity.setSysUserId("");
|
||||
entity.setOnboardingState("0");
|
||||
entity.setListingState("1");
|
||||
entity.setClerkState("1");
|
||||
entity.setOnlineState("1");
|
||||
entity.setToken(token);
|
||||
clerkUserInfoService.save(entity);
|
||||
} else {
|
||||
PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
|
||||
patch.setId(clerkId);
|
||||
patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
patch.setOpenid(openId);
|
||||
patch.setAvatar("https://example.com/avatar.png");
|
||||
patch.setSysUserId("");
|
||||
patch.setOnboardingState("0");
|
||||
patch.setListingState("1");
|
||||
patch.setClerkState("1");
|
||||
patch.setOnlineState("1");
|
||||
patch.setDeleted(Boolean.FALSE);
|
||||
patch.setToken(token);
|
||||
clerkUserInfoService.updateById(patch);
|
||||
}
|
||||
|
||||
WxMpUser wxMpUser = new WxMpUser();
|
||||
wxMpUser.setSubscribe(true);
|
||||
when(wxMpService.getUserService().userInfo(anyString())).thenReturn(wxMpUser);
|
||||
|
||||
mockMvc.perform(get("/wx/oauth2/clerk/logout")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(401))
|
||||
.andExpect(jsonPath("$.message").value("你已离职,需要复职请联系店铺管理员"));
|
||||
|
||||
PlayClerkUserInfoEntity after = clerkUserInfoService.getById(clerkId);
|
||||
if (after == null) {
|
||||
throw new AssertionError("Expected clerk to exist");
|
||||
}
|
||||
if (!"empty".equals(after.getToken())) {
|
||||
throw new AssertionError("Expected clerk token to be invalidated to 'empty'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
|
||||
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
|
||||
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
|
||||
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
|
||||
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
|
||||
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
|
||||
import com.starry.admin.modules.shop.module.constant.GiftHistory;
|
||||
import com.starry.admin.modules.shop.module.constant.GiftState;
|
||||
import com.starry.admin.modules.shop.module.constant.GiftType;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
|
||||
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import org.assertj.core.api.Assertions;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
class WxBlindBoxControllerApiTest extends WxCustomOrderApiTestSupport {
|
||||
|
||||
@Autowired
|
||||
private BlindBoxConfigService blindBoxConfigService;
|
||||
|
||||
@Autowired
|
||||
private BlindBoxPoolMapper blindBoxPoolMapper;
|
||||
|
||||
@Autowired
|
||||
private IPlayGiftInfoService giftInfoService;
|
||||
|
||||
@Autowired
|
||||
private BlindBoxRewardMapper blindBoxRewardMapper;
|
||||
|
||||
private String customerToken;
|
||||
|
||||
private final java.util.List<String> configIdsToCleanup = new java.util.ArrayList<>();
|
||||
private final java.util.List<Long> poolIdsToCleanup = new java.util.ArrayList<>();
|
||||
private final java.util.List<String> giftIdsToCleanup = new java.util.ArrayList<>();
|
||||
private final java.util.List<String> rewardIdsToCleanup = new java.util.ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ensureTenantContext();
|
||||
resetCustomerBalance();
|
||||
customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ensureTenantContext();
|
||||
for (String id : rewardIdsToCleanup) {
|
||||
blindBoxRewardMapper.deleteById(id);
|
||||
}
|
||||
for (Long id : poolIdsToCleanup) {
|
||||
blindBoxPoolMapper.deleteById(id);
|
||||
}
|
||||
for (String id : configIdsToCleanup) {
|
||||
blindBoxConfigService.removeById(id);
|
||||
}
|
||||
for (String id : giftIdsToCleanup) {
|
||||
giftInfoService.removeById(id);
|
||||
}
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listConfigsRejectsMissingToken__covers_BB_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/blind-box/config/list")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(401))
|
||||
.andExpect(jsonPath("$.message").value("token为空"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listConfigsReturnsOnlyActiveConfigsForTenant__covers_BB_002() throws Exception {
|
||||
ensureTenantContext();
|
||||
BlindBoxConfigEntity active = new BlindBoxConfigEntity();
|
||||
active.setId("bb-active-" + IdUtils.getUuid().substring(0, 8));
|
||||
active.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
active.setName("API盲盒-上架");
|
||||
active.setPrice(new BigDecimal("9.99"));
|
||||
active.setStatus(1);
|
||||
blindBoxConfigService.save(active);
|
||||
configIdsToCleanup.add(active.getId());
|
||||
|
||||
BlindBoxConfigEntity inactive = new BlindBoxConfigEntity();
|
||||
inactive.setId("bb-off-" + IdUtils.getUuid().substring(0, 8));
|
||||
inactive.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
inactive.setName("API盲盒-下架");
|
||||
inactive.setPrice(new BigDecimal("9.99"));
|
||||
inactive.setStatus(0);
|
||||
blindBoxConfigService.save(inactive);
|
||||
configIdsToCleanup.add(inactive.getId());
|
||||
|
||||
mockMvc.perform(get("/wx/blind-box/config/list")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.hasItem(active.getId())))
|
||||
.andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem(inactive.getId()))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void purchaseRejectsTenantMismatch__covers_BB_003() throws Exception {
|
||||
ensureTenantContext();
|
||||
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||
config.setId("bb-other-" + IdUtils.getUuid().substring(0, 8));
|
||||
config.setTenantId("other-tenant");
|
||||
config.setName("API盲盒-其他租户");
|
||||
config.setPrice(new BigDecimal("9.99"));
|
||||
config.setStatus(1);
|
||||
blindBoxConfigService.save(config);
|
||||
configIdsToCleanup.add(config.getId());
|
||||
|
||||
String payload = objectMapper.createObjectNode()
|
||||
.put("blindBoxId", config.getId())
|
||||
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.put("weiChatCode", "apitest-customer-wx")
|
||||
.toString();
|
||||
|
||||
mockMvc.perform(post("/wx/blind-box/order/purchase")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("盲盒不存在"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchRejectsWhenRewardNotFound__covers_BB_005() throws Exception {
|
||||
ensureTenantContext();
|
||||
String payload = objectMapper.createObjectNode()
|
||||
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.toString();
|
||||
|
||||
mockMvc.perform(post("/wx/blind-box/reward/not-found-" + IdUtils.getUuid().substring(0, 6) + "/dispatch")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("盲盒奖励不存在"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listRewardsFiltersByStatus__covers_BB_007() throws Exception {
|
||||
ensureTenantContext();
|
||||
String configId = "bb-list-" + IdUtils.getUuid().substring(0, 6);
|
||||
String giftId = "bb-gift-" + IdUtils.getUuid().substring(0, 6);
|
||||
Long poolId = null;
|
||||
|
||||
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||
config.setId(configId);
|
||||
config.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
config.setName("API盲盒-列表");
|
||||
config.setPrice(new BigDecimal("19.90"));
|
||||
config.setStatus(1);
|
||||
blindBoxConfigService.save(config);
|
||||
configIdsToCleanup.add(configId);
|
||||
|
||||
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
|
||||
gift.setId(giftId);
|
||||
gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
gift.setName("盲盒礼物-列表");
|
||||
gift.setHistory(GiftHistory.CURRENT.getCode());
|
||||
gift.setState(GiftState.ACTIVE.getCode());
|
||||
gift.setType(GiftType.NORMAL.getCode());
|
||||
gift.setUrl("https://example.com/apitest/blindbox-list.png");
|
||||
gift.setPrice(new BigDecimal("9.99"));
|
||||
giftInfoService.save(gift);
|
||||
giftIdsToCleanup.add(giftId);
|
||||
|
||||
BlindBoxPoolEntity entry = new BlindBoxPoolEntity();
|
||||
entry.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entry.setBlindBoxId(configId);
|
||||
entry.setRewardGiftId(giftId);
|
||||
entry.setRewardPrice(gift.getPrice());
|
||||
entry.setWeight(100);
|
||||
entry.setRemainingStock(10);
|
||||
entry.setStatus(1);
|
||||
entry.setValidFrom(LocalDateTime.now().minusDays(1));
|
||||
entry.setValidTo(LocalDateTime.now().plusDays(1));
|
||||
blindBoxPoolMapper.insert(entry);
|
||||
poolId = entry.getId();
|
||||
poolIdsToCleanup.add(poolId);
|
||||
|
||||
String purchasePayload = objectMapper.createObjectNode()
|
||||
.put("blindBoxId", configId)
|
||||
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.put("weiChatCode", "apitest-customer-wx")
|
||||
.toString();
|
||||
|
||||
MvcResult firstPurchase = mockMvc.perform(post("/wx/blind-box/order/purchase")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(purchasePayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode rewardNode = objectMapper.readTree(firstPurchase.getResponse().getContentAsString())
|
||||
.path("data").path("reward");
|
||||
String usedRewardId = rewardNode.path("rewardId").asText();
|
||||
Assertions.assertThat(usedRewardId).isNotBlank();
|
||||
rewardIdsToCleanup.add(usedRewardId);
|
||||
|
||||
mockMvc.perform(post("/wx/blind-box/reward/" + usedRewardId + "/dispatch")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.createObjectNode()
|
||||
.put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.status").value("USED"));
|
||||
|
||||
MvcResult secondPurchase = mockMvc.perform(post("/wx/blind-box/order/purchase")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(purchasePayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
String unusedRewardId = objectMapper.readTree(secondPurchase.getResponse().getContentAsString())
|
||||
.path("data").path("reward").path("rewardId").asText();
|
||||
Assertions.assertThat(unusedRewardId).isNotBlank();
|
||||
rewardIdsToCleanup.add(unusedRewardId);
|
||||
|
||||
mockMvc.perform(get("/wx/blind-box/reward/list")
|
||||
.param("status", "UNUSED")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.hasItem(unusedRewardId)))
|
||||
.andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem(usedRewardId))));
|
||||
|
||||
mockMvc.perform(get("/wx/blind-box/reward/list")
|
||||
.param("status", "USED")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.hasItem(usedRewardId)))
|
||||
.andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem(unusedRewardId))));
|
||||
|
||||
BlindBoxRewardEntity storedUsed = blindBoxRewardMapper.selectById(usedRewardId);
|
||||
Assertions.assertThat(storedUsed).isNotNull();
|
||||
Assertions.assertThat(storedUsed.getStatus()).isEqualTo("USED");
|
||||
Assertions.assertThat(storedUsed.getUsedClerkId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
if (Objects.nonNull(poolId)) {
|
||||
blindBoxPoolMapper.deleteById(poolId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
|
||||
private IPlayClerkGiftInfoService clerkGiftInfoService;
|
||||
|
||||
@Test
|
||||
void blindBoxPurchaseFailsWhenBalanceInsufficient() throws Exception {
|
||||
void purchaseRejectsWhenBalanceInsufficient__covers_BB_008() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String configId = "blind-" + IdUtils.getUuid();
|
||||
try {
|
||||
@@ -99,7 +99,7 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport {
|
||||
}
|
||||
|
||||
@Test
|
||||
void blindBoxPurchaseAndDispatchSucceedWhenGiftInactive() throws Exception {
|
||||
void purchaseCreatesCompletedOrderAndRewardAndDispatchMarksUsed__covers_BB_004__covers_BB_006() throws Exception {
|
||||
String configId = "blind-inactive-" + IdUtils.getUuid().substring(0, 6);
|
||||
String giftId = "gift-inactive-" + IdUtils.getUuid().substring(0, 6);
|
||||
Long poolId = null;
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.starry.admin.modules.weichat.entity.clerk.PlayClerkUserInfoResultVo;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -210,7 +211,7 @@ class WxClerkAlbumUpdateApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAlbumRejectsEmptyAlbumPayload() throws Exception {
|
||||
void updateAlbumRejectsEmptyAlbumPayload__covers_CLK_010() throws Exception {
|
||||
ensureTenantContext();
|
||||
String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
|
||||
String clerkToken = wxTokenService.createWxUserToken(clerkId);
|
||||
@@ -228,18 +229,53 @@ class WxClerkAlbumUpdateApiTest extends AbstractApiTest {
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
String body = result.getResponse().getContentAsString();
|
||||
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
com.fasterxml.jackson.databind.JsonNode root = new ObjectMapper().readTree(body);
|
||||
assertThat(root.path("code").asInt())
|
||||
.as("empty album should be rejected, response=%s", body)
|
||||
.isEqualTo(500);
|
||||
assertThat(root.path("message").asText())
|
||||
.as("error message for empty album should be present, response=%s", body)
|
||||
.isNotBlank();
|
||||
.as("error message for empty album should be pinned, response=%s", body)
|
||||
.isEqualTo("最少上传一张照片");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAlbumAllowsMixedLegacyUrlsAndNewMediaIdsForReview() throws Exception {
|
||||
void updateAlbumRejectsInvalidNewMediaOwnershipOrStatus__covers_CLK_011() throws Exception {
|
||||
ensureTenantContext();
|
||||
String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
|
||||
String clerkToken = wxTokenService.createWxUserToken(clerkId);
|
||||
clerkUserInfoService.updateTokenById(clerkId, clerkToken);
|
||||
|
||||
PlayMediaEntity invalid = new PlayMediaEntity();
|
||||
invalid.setId("media-invalid-" + com.starry.common.utils.IdUtils.getUuid().substring(0, 8));
|
||||
invalid.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
invalid.setOwnerType(com.starry.admin.modules.media.enums.MediaOwnerType.CLERK);
|
||||
invalid.setOwnerId("other-clerk");
|
||||
invalid.setKind("image");
|
||||
invalid.setStatus(com.starry.admin.modules.media.enums.MediaStatus.READY.getCode());
|
||||
invalid.setUrl("https://example.com/invalid.png");
|
||||
mediaService.save(invalid);
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.putArray("album").add(invalid.getId());
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/wx/clerk/user/updateAlbum")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
com.fasterxml.jackson.databind.JsonNode root = new ObjectMapper().readTree(body);
|
||||
assertThat(root.path("code").asInt()).isEqualTo(500);
|
||||
assertThat(root.path("message").asText()).isEqualTo("存在无效的照片/视频,请刷新后重试");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAlbumAllowsMixedLegacyUrlsAndNewMediaIdsForReview__covers_CLK_012() throws Exception {
|
||||
ensureTenantContext();
|
||||
String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
|
||||
String clerkToken = wxTokenService.createWxUserToken(clerkId);
|
||||
@@ -286,6 +322,17 @@ class WxClerkAlbumUpdateApiTest extends AbstractApiTest {
|
||||
assertThat(reviewCountAfter)
|
||||
.as("mixed legacy URLs and new media ids should create exactly one new review record")
|
||||
.isEqualTo(reviewCountBefore + 1);
|
||||
|
||||
ensureTenantContext();
|
||||
com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity review =
|
||||
dataReviewInfoService.lambdaQuery()
|
||||
.eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getClerkId, clerkId)
|
||||
.eq(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getDataType, "2")
|
||||
.orderByDesc(com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity::getCreatedTime)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
assertThat(review).isNotNull();
|
||||
assertThat(review.getDataContent()).contains(legacyUrl1, legacyUrl2, media.getId());
|
||||
}
|
||||
|
||||
private PlayMediaEntity seedMedia(String clerkId) {
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.common.exception.ServiceException;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkDataReviewInfoEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserReviewInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkDataReviewInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserReviewInfoService;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxCustomMpService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.redis.RedisCache;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
class WxClerkControllerUserApiTest extends AbstractApiTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkDataReviewInfoService dataReviewInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserReviewInfoService clerkUserReviewInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderEvaluateInfoService orderEvaluateInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@MockBean
|
||||
private WxCustomMpService wxCustomMpService;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryPerformanceInfoIsStableForSameInput__covers_CLK_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
String payload = "{\"startTime\":\"2026-01-01\",\"endTime\":\"2026-01-02\"}";
|
||||
MvcResult first = mockMvc.perform(post("/wx/clerk/user/queryPerformanceInfo")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
MvcResult second = mockMvc.perform(post("/wx/clerk/user/queryPerformanceInfo")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
assertThat(second.getResponse().getContentAsString(StandardCharsets.UTF_8))
|
||||
.isEqualTo(first.getResponse().getContentAsString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryLevelInfoReturnsPinnedRankingList__covers_CLK_002() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/clerk/user/queryLevelInfo")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.levelAndRanking", hasSize(6)))
|
||||
.andExpect(jsonPath("$.data.levelAndRanking[0].levelName").value("女神"))
|
||||
.andExpect(jsonPath("$.data.levelAndRanking[5].levelName").value("普通"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendCodeWritesRedisKeyAndReturnsCode__covers_CLK_003() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
String areaCode = "+86";
|
||||
String phone = "13900001234";
|
||||
String payload = "{\"areaCode\":\"" + areaCode + "\",\"phone\":\"" + phone + "\"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/wx/clerk/user/sendCode")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8));
|
||||
String code = root.path("data").asText();
|
||||
assertThat(code).hasSize(4);
|
||||
|
||||
String codeKey = "login_codes:" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "_" + SecureUtil.md5(areaCode + phone);
|
||||
String stored = redisCache.getCacheObject(codeKey);
|
||||
assertThat(stored).isEqualTo(code);
|
||||
|
||||
Long ttlSeconds = redisCache.redisTemplate.getExpire(codeKey, TimeUnit.SECONDS);
|
||||
assertThat(ttlSeconds).isNotNull();
|
||||
assertThat(ttlSeconds).isGreaterThan(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindCodeRejectsWrongCode__covers_CLK_004() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
String areaCode = "+86";
|
||||
String phone = "13900002222";
|
||||
String codeKey = "login_codes:" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "_" + SecureUtil.md5(areaCode + phone);
|
||||
redisCache.setCacheObject(codeKey, "1234", 5L, TimeUnit.MINUTES);
|
||||
|
||||
String payload = "{\"areaCode\":\"" + areaCode + "\",\"phone\":\"" + phone + "\",\"code\":\"0000\"}";
|
||||
mockMvc.perform(post("/wx/clerk/user/bindCode")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("验证码错误"));
|
||||
|
||||
assertThat(redisCache.<String>getCacheObject(codeKey)).isEqualTo("1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindCodeUpdatesPhoneAndClearsRedis__covers_CLK_005() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
PlayClerkUserInfoEntity before = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
assertThat(before).isNotNull();
|
||||
String originalPhone = before.getPhone();
|
||||
|
||||
String areaCode = "+86";
|
||||
String phone = "13900003333";
|
||||
String codeKey = "login_codes:" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "_" + SecureUtil.md5(areaCode + phone);
|
||||
redisCache.setCacheObject(codeKey, "9999", 5L, TimeUnit.MINUTES);
|
||||
|
||||
try {
|
||||
String payload = "{\"areaCode\":\"" + areaCode + "\",\"phone\":\"" + phone + "\",\"code\":\"9999\"}";
|
||||
mockMvc.perform(post("/wx/clerk/user/bindCode")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("成功"));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayClerkUserInfoEntity after = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
assertThat(after.getPhone()).isEqualTo(phone);
|
||||
assertThat(redisCache.<String>getCacheObject(codeKey)).isNull();
|
||||
} finally {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
|
||||
patch.setId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
patch.setPhone(originalPhone);
|
||||
clerkUserInfoService.updateById(patch);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void userAddRejectsWhenAlreadyClerk__covers_CLK_006() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
doNothing().when(wxCustomMpService).checkSubscribeThrowsExp(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("nickname", "申请人");
|
||||
payload.put("sex", "2");
|
||||
payload.put("age", 18);
|
||||
payload.put("weiChatCode", "wx-apply");
|
||||
payload.put("province", "Guangdong");
|
||||
payload.put("city", "Shenzhen");
|
||||
payload.put("audio", "https://oss.example/audio.mp3");
|
||||
payload.set("album", objectMapper.createArrayNode());
|
||||
|
||||
mockMvc.perform(post("/wx/clerk/user/add")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("当前用户已经是店员"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userAddRejectsWhenPendingReviewExists__covers_CLK_007() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
doNothing().when(wxCustomMpService).checkSubscribeThrowsExp(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
|
||||
String clerkId = "clerk-pending-" + IdUtils.getUuid().substring(0, 8);
|
||||
ensureClerk(clerkId, "openid-" + clerkId, "0", "");
|
||||
String clerkToken = ensureClerkToken(clerkId);
|
||||
|
||||
PlayClerkUserReviewInfoEntity review = new PlayClerkUserReviewInfoEntity();
|
||||
review.setId("review-" + IdUtils.getUuid().substring(0, 8));
|
||||
review.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
review.setClerkId(clerkId);
|
||||
review.setReviewState("0");
|
||||
review.setNickname("pending");
|
||||
review.setSex("2");
|
||||
review.setAge(18);
|
||||
review.setProvince("GD");
|
||||
review.setCity("SZ");
|
||||
review.setWeiChatCode("wx");
|
||||
review.setAudio("https://oss.example/audio.mp3");
|
||||
review.setAddTime(LocalDateTime.now());
|
||||
clerkUserReviewInfoService.save(review);
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("nickname", "申请人");
|
||||
payload.put("sex", "2");
|
||||
payload.put("age", 18);
|
||||
payload.put("weiChatCode", "wx-apply");
|
||||
payload.put("province", "Guangdong");
|
||||
payload.put("city", "Shenzhen");
|
||||
payload.put("audio", "https://oss.example/audio.mp3");
|
||||
payload.set("album", objectMapper.createArrayNode());
|
||||
|
||||
mockMvc.perform(post("/wx/clerk/user/add")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("已有申请未审核"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userAddRequiresSubscribe__covers_CLK_008() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String clerkId = "clerk-unsub-" + IdUtils.getUuid().substring(0, 8);
|
||||
ensureClerk(clerkId, "openid-" + clerkId, "0", "");
|
||||
String clerkToken = ensureClerkToken(clerkId);
|
||||
|
||||
doThrow(new ServiceException("请先关注公众号然后再来使用系统~"))
|
||||
.when(wxCustomMpService)
|
||||
.checkSubscribeThrowsExp(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("nickname", "申请人");
|
||||
payload.put("sex", "2");
|
||||
payload.put("age", 18);
|
||||
payload.put("weiChatCode", "wx-apply");
|
||||
payload.put("province", "Guangdong");
|
||||
payload.put("city", "Shenzhen");
|
||||
payload.put("audio", "https://oss.example/audio.mp3");
|
||||
payload.set("album", objectMapper.createArrayNode());
|
||||
|
||||
mockMvc.perform(post("/wx/clerk/user/add")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("请先关注公众号然后再来使用系统~"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNicknameCreatesDataReviewRow__covers_CLK_009() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String clerkToken = ensureClerkToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
String nickname = "nick-" + IdUtils.getUuid().substring(0, 6);
|
||||
String payload = "{\"nickname\":\"" + nickname + "\"}";
|
||||
|
||||
mockMvc.perform(post("/wx/clerk/user/updateNickname")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayClerkDataReviewInfoEntity latest = dataReviewInfoService.lambdaQuery()
|
||||
.eq(PlayClerkDataReviewInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(PlayClerkDataReviewInfoEntity::getDataType, "0")
|
||||
.orderByDesc(PlayClerkDataReviewInfoEntity::getCreatedTime)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
assertThat(latest).isNotNull();
|
||||
assertThat(latest.getReviewState()).isEqualTo("0");
|
||||
assertThat(latest.getDataContent()).isEqualTo(List.of(nickname));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryEvaluateByPageForcesHiddenVisible__covers_CLK_020() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
String visibleOrderId = "ordevl-v-" + IdUtils.getUuid().substring(0, 8);
|
||||
String hiddenOrderId = "ordevl-h-" + IdUtils.getUuid().substring(0, 8);
|
||||
String visibleId = "eval-visible-" + IdUtils.getUuid().substring(0, 8);
|
||||
String hiddenId = "eval-hidden-" + IdUtils.getUuid().substring(0, 8);
|
||||
String keyword = "kw-" + IdUtils.getUuid().substring(0, 6);
|
||||
try {
|
||||
PlayOrderInfoEntity visibleOrder = new PlayOrderInfoEntity();
|
||||
visibleOrder.setId(visibleOrderId);
|
||||
visibleOrder.setOrderNo("EVAL-" + IdUtils.getUuid().substring(0, 4));
|
||||
visibleOrder.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
visibleOrder.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode());
|
||||
visibleOrder.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
|
||||
visibleOrder.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
|
||||
visibleOrder.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
visibleOrder.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
visibleOrder.setOrderMoney(new java.math.BigDecimal("99.00"));
|
||||
visibleOrder.setFinalAmount(new java.math.BigDecimal("99.00"));
|
||||
visibleOrder.setPayMethod("0");
|
||||
visibleOrder.setPayState("1");
|
||||
visibleOrder.setCommodityId("svc-basic");
|
||||
visibleOrder.setCommodityType("1");
|
||||
visibleOrder.setCommodityName("Weixin Order");
|
||||
visibleOrder.setCommodityPrice(new java.math.BigDecimal("99.00"));
|
||||
visibleOrder.setCommodityNumber("1");
|
||||
visibleOrder.setServiceDuration("60min");
|
||||
visibleOrder.setCreatedTime(new java.util.Date());
|
||||
visibleOrder.setUpdatedTime(new java.util.Date());
|
||||
orderInfoService.save(visibleOrder);
|
||||
|
||||
PlayOrderInfoEntity hiddenOrder = new PlayOrderInfoEntity();
|
||||
hiddenOrder.setId(hiddenOrderId);
|
||||
hiddenOrder.setOrderNo("EVAL-" + IdUtils.getUuid().substring(0, 4));
|
||||
hiddenOrder.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
hiddenOrder.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode());
|
||||
hiddenOrder.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
|
||||
hiddenOrder.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
|
||||
hiddenOrder.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
hiddenOrder.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
hiddenOrder.setOrderMoney(new java.math.BigDecimal("99.00"));
|
||||
hiddenOrder.setFinalAmount(new java.math.BigDecimal("99.00"));
|
||||
hiddenOrder.setPayMethod("0");
|
||||
hiddenOrder.setPayState("1");
|
||||
hiddenOrder.setCommodityId("svc-basic");
|
||||
hiddenOrder.setCommodityType("1");
|
||||
hiddenOrder.setCommodityName("Weixin Order");
|
||||
hiddenOrder.setCommodityPrice(new java.math.BigDecimal("99.00"));
|
||||
hiddenOrder.setCommodityNumber("1");
|
||||
hiddenOrder.setServiceDuration("60min");
|
||||
hiddenOrder.setCreatedTime(new java.util.Date());
|
||||
hiddenOrder.setUpdatedTime(new java.util.Date());
|
||||
orderInfoService.save(hiddenOrder);
|
||||
|
||||
PlayOrderEvaluateInfoEntity visible = new PlayOrderEvaluateInfoEntity();
|
||||
visible.setId(visibleId);
|
||||
visible.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
visible.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
visible.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
visible.setOrderId(visibleOrderId);
|
||||
visible.setAnonymous("0");
|
||||
visible.setEvaluateType("0");
|
||||
visible.setEvaluateLevel(5);
|
||||
visible.setEvaluateCon(keyword);
|
||||
visible.setEvaluateTime(new java.util.Date());
|
||||
visible.setHidden("0");
|
||||
orderEvaluateInfoService.save(visible);
|
||||
|
||||
PlayOrderEvaluateInfoEntity hidden = new PlayOrderEvaluateInfoEntity();
|
||||
hidden.setId(hiddenId);
|
||||
hidden.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
hidden.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
hidden.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
hidden.setOrderId(hiddenOrderId);
|
||||
hidden.setAnonymous("0");
|
||||
hidden.setEvaluateType("0");
|
||||
hidden.setEvaluateLevel(1);
|
||||
hidden.setEvaluateCon(keyword);
|
||||
hidden.setEvaluateTime(new java.util.Date());
|
||||
hidden.setHidden("1");
|
||||
orderEvaluateInfoService.save(hidden);
|
||||
|
||||
String payload = "{\"pageNum\":1,\"pageSize\":20,\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\","
|
||||
+ "\"hidden\":\"1\",\"evaluateCon\":\"" + keyword + "\"}";
|
||||
MvcResult result = mockMvc.perform(post("/wx/clerk/user/queryEvaluateByPage")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode records = objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8))
|
||||
.path("data");
|
||||
assertThat(records)
|
||||
.anyMatch(node -> visibleId.equals(node.path("id").asText()))
|
||||
.noneMatch(node -> hiddenId.equals(node.path("id").asText()));
|
||||
} finally {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
orderEvaluateInfoService.removeById(visibleId);
|
||||
orderEvaluateInfoService.removeById(hiddenId);
|
||||
orderInfoService.removeById(visibleOrderId);
|
||||
orderInfoService.removeById(hiddenOrderId);
|
||||
}
|
||||
}
|
||||
|
||||
private String ensureClerkToken(String clerkId) {
|
||||
String token = wxTokenService.createWxUserToken(clerkId);
|
||||
clerkUserInfoService.updateTokenById(clerkId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
private void ensureClerk(String clerkId, String openid, String clerkState, String sysUserId) {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
|
||||
patch.setId(clerkId);
|
||||
patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
patch.setOpenid(openid);
|
||||
patch.setNickname("API Clerk " + clerkId);
|
||||
patch.setSysUserId(sysUserId);
|
||||
patch.setClerkState(clerkState);
|
||||
patch.setOnboardingState("1");
|
||||
patch.setListingState("1");
|
||||
patch.setOnlineState("1");
|
||||
if (clerkUserInfoService.getById(clerkId) == null) {
|
||||
clerkUserInfoService.save(patch);
|
||||
} else {
|
||||
patch.setDeleted(Boolean.FALSE);
|
||||
clerkUserInfoService.updateById(patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@@ -31,9 +32,11 @@ import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.enums.ClerkReviewState;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Comparator;
|
||||
@@ -43,6 +46,7 @@ import javax.imageio.ImageIO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
@@ -73,7 +77,31 @@ class WxClerkMediaControllerApiTest extends AbstractApiTest {
|
||||
private IOssFileService ossFileService;
|
||||
|
||||
@Test
|
||||
void clerkCanUploadImageMediaAndPersistUrl() throws Exception {
|
||||
void uploadRejectsEmptyFile__covers_MED_001() throws Exception {
|
||||
ensureTenantContext();
|
||||
String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
|
||||
MockMultipartFile emptyFile = new MockMultipartFile(
|
||||
"file",
|
||||
"empty.png",
|
||||
"image/png",
|
||||
new byte[0]);
|
||||
|
||||
mockMvc.perform(multipart("/wx/clerk/media/upload")
|
||||
.file(emptyFile)
|
||||
.param("usage", "profile")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("请选择要上传的文件"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkCanUploadImageMediaAndPersistUrl__covers_MED_002() throws Exception {
|
||||
ensureTenantContext();
|
||||
String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
@@ -119,6 +147,104 @@ class WxClerkMediaControllerApiTest extends AbstractApiTest {
|
||||
assertThat(persisted.getUrl()).isEqualTo(ossUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadRejectsOversizedVideo__covers_MED_003() throws Exception {
|
||||
ensureTenantContext();
|
||||
String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
|
||||
byte[] oversized = new byte[(int) (30L * 1024 * 1024 + 1)];
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"big.mp4",
|
||||
"video/mp4",
|
||||
oversized);
|
||||
|
||||
mockMvc.perform(multipart("/wx/clerk/media/upload")
|
||||
.file(file)
|
||||
.param("usage", "profile")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("视频大小不能超过30MB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadRejectsVideoDurationOver45Seconds__covers_MED_004() throws Exception {
|
||||
ensureTenantContext();
|
||||
String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
|
||||
Path videoPath = Files.createTempFile("apitest-long-video-", ".mp4");
|
||||
try {
|
||||
Process process = new ProcessBuilder(
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=black:s=16x16:r=1",
|
||||
"-t",
|
||||
"46",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
videoPath.toString())
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
throw new AssertionError("ffmpeg failed with exitCode=" + exitCode + ": "
|
||||
+ new String(process.getInputStream().readAllBytes()));
|
||||
}
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"long.mp4",
|
||||
"video/mp4",
|
||||
Files.readAllBytes(videoPath));
|
||||
|
||||
mockMvc.perform(multipart("/wx/clerk/media/upload")
|
||||
.file(file)
|
||||
.param("usage", "profile")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("视频时长不能超过45秒"));
|
||||
} finally {
|
||||
Files.deleteIfExists(videoPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadPinsDbUniqueConstraintOnClerkUsageMedia__covers_MED_009() throws Exception {
|
||||
ensureTenantContext();
|
||||
String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
|
||||
when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
|
||||
.thenReturn("https://oss.mock/apitest/dup.png");
|
||||
String mediaId = extractMediaIdFromUpload(buildTinyPng("dup.png"), clerkToken);
|
||||
assertThat(mediaId).isNotBlank();
|
||||
|
||||
PlayClerkMediaAssetEntity duplicate = new PlayClerkMediaAssetEntity();
|
||||
duplicate.setId("asset-dup-" + IdUtils.getUuid().substring(0, 8));
|
||||
duplicate.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
duplicate.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
duplicate.setUsage(ClerkMediaUsage.PROFILE.getCode());
|
||||
duplicate.setMediaId(mediaId);
|
||||
duplicate.setReviewState(ClerkMediaReviewState.DRAFT.getCode());
|
||||
duplicate.setDeleted(false);
|
||||
|
||||
assertThatThrownBy(() -> mediaAssetService.save(duplicate))
|
||||
.isInstanceOf(DuplicateKeyException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkCanUploadVideoMediaAndPersistUrl() throws Exception {
|
||||
ensureTenantContext();
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.common.oss.service.IOssFileService;
|
||||
import com.starry.admin.modules.clerk.enums.ClerkMediaReviewState;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkMediaAssetEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkMediaAssetService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.media.entity.PlayMediaEntity;
|
||||
import com.starry.admin.modules.media.enums.MediaOwnerType;
|
||||
import com.starry.admin.modules.media.enums.MediaStatus;
|
||||
import com.starry.admin.modules.media.service.IPlayMediaService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
import javax.imageio.ImageIO;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
class WxClerkMediaControllerEndpointsApiTest extends AbstractApiTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkMediaAssetService clerkMediaAssetService;
|
||||
|
||||
@Autowired
|
||||
private IPlayMediaService mediaService;
|
||||
|
||||
@MockBean
|
||||
private IOssFileService ossFileService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateOrderDistinctsMediaIdsAndPersistsOrderIndex__covers_MED_005() throws Exception {
|
||||
String suffix = Long.toString(System.nanoTime(), 36);
|
||||
String clerkId = ensureActiveClerk("cm-end-" + suffix, "oid-cm-end-" + suffix);
|
||||
String clerkToken = wxTokenService.createWxUserToken(clerkId);
|
||||
clerkUserInfoService.updateTokenById(clerkId, clerkToken);
|
||||
|
||||
when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
|
||||
.thenReturn(
|
||||
"https://oss.mock/apitest/media-a.png",
|
||||
"https://oss.mock/apitest/media-b.png");
|
||||
|
||||
String mediaIdA = uploadTinyPng("media-a.png", clerkToken);
|
||||
String mediaIdB = uploadTinyPng("media-b.png", clerkToken);
|
||||
|
||||
String payload = "{\"usage\":\"profile\",\"mediaIds\":[\"" + mediaIdB + "\",\"" + mediaIdA + "\",\"" + mediaIdB
|
||||
+ "\"]}";
|
||||
mockMvc.perform(put("/wx/clerk/media/order")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
List<PlayClerkMediaAssetEntity> assets = clerkMediaAssetService.lambdaQuery()
|
||||
.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
|
||||
.eq(PlayClerkMediaAssetEntity::getUsage, "profile")
|
||||
.eq(PlayClerkMediaAssetEntity::getDeleted, false)
|
||||
.in(PlayClerkMediaAssetEntity::getMediaId, List.of(mediaIdA, mediaIdB))
|
||||
.list();
|
||||
if (assets.size() != 2) {
|
||||
throw new AssertionError("Expected exactly 2 active assets after distinct ordering");
|
||||
}
|
||||
PlayClerkMediaAssetEntity assetA = assets.stream().filter(a -> mediaIdA.equals(a.getMediaId())).findFirst()
|
||||
.orElseThrow();
|
||||
PlayClerkMediaAssetEntity assetB = assets.stream().filter(a -> mediaIdB.equals(a.getMediaId())).findFirst()
|
||||
.orElseThrow();
|
||||
if (!Integer.valueOf(1).equals(assetA.getOrderIndex())) {
|
||||
throw new AssertionError("Expected orderIndex=1 for media-a");
|
||||
}
|
||||
if (!Integer.valueOf(0).equals(assetB.getOrderIndex())) {
|
||||
throw new AssertionError("Expected orderIndex=0 for media-b");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteMarksAssetRejectedAndKeepsDeletedFalse__covers_MED_006() throws Exception {
|
||||
String suffix = Long.toString(System.nanoTime(), 36);
|
||||
String clerkId = ensureActiveClerk("cm-del-" + suffix, "oid-cm-del-" + suffix);
|
||||
String clerkToken = wxTokenService.createWxUserToken(clerkId);
|
||||
clerkUserInfoService.updateTokenById(clerkId, clerkToken);
|
||||
|
||||
when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
|
||||
.thenReturn("https://oss.mock/apitest/media-delete.png");
|
||||
|
||||
String mediaId = uploadTinyPng("media-delete.png", clerkToken);
|
||||
|
||||
PlayClerkMediaAssetEntity beforeDelete = clerkMediaAssetService.lambdaQuery()
|
||||
.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
|
||||
.eq(PlayClerkMediaAssetEntity::getMediaId, mediaId)
|
||||
.one();
|
||||
if (beforeDelete == null) {
|
||||
throw new AssertionError("Expected clerk asset to exist before delete");
|
||||
}
|
||||
if (beforeDelete.getDeleted() == null) {
|
||||
beforeDelete.setDeleted(false);
|
||||
clerkMediaAssetService.updateById(beforeDelete);
|
||||
}
|
||||
|
||||
mockMvc.perform(delete("/wx/clerk/media/" + mediaId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
List<PlayClerkMediaAssetEntity> assetsAfterDelete = clerkMediaAssetService.lambdaQuery()
|
||||
.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
|
||||
.eq(PlayClerkMediaAssetEntity::getMediaId, mediaId)
|
||||
.list();
|
||||
if (assetsAfterDelete.isEmpty()) {
|
||||
throw new AssertionError("Expected clerk asset to exist");
|
||||
}
|
||||
boolean anyRejected = assetsAfterDelete.stream()
|
||||
.anyMatch(item -> ClerkMediaReviewState.REJECTED.getCode().equals(item.getReviewState()));
|
||||
if (!anyRejected) {
|
||||
throw new AssertionError("Expected asset.reviewState=rejected; got: "
|
||||
+ assetsAfterDelete.stream()
|
||||
.map(item -> item.getId() + "(deleted=" + item.getDeleted() + ", state="
|
||||
+ item.getReviewState() + ")")
|
||||
.collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
boolean anyDeleted = assetsAfterDelete.stream().anyMatch(item -> Boolean.TRUE.equals(item.getDeleted()));
|
||||
if (anyDeleted) {
|
||||
throw new AssertionError("Expected asset.deleted to remain false; got: "
|
||||
+ assetsAfterDelete.stream()
|
||||
.map(item -> item.getId() + "(deleted=" + item.getDeleted() + ", state="
|
||||
+ item.getReviewState() + ")")
|
||||
.collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
|
||||
PlayMediaEntity media = mediaService.getById(mediaId);
|
||||
if (media == null) {
|
||||
throw new AssertionError("Expected media to exist");
|
||||
}
|
||||
if (!MediaStatus.REJECTED.getCode().equals(media.getStatus())) {
|
||||
throw new AssertionError("Expected media.status=rejected after delete");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listDraftReturnsOnlyDraftPendingRejected__covers_MED_007__covers_MED_008() throws Exception {
|
||||
String suffix = Long.toString(System.nanoTime(), 36);
|
||||
String clerkId = ensureActiveClerk("cm-lst-" + suffix, "oid-cm-lst-" + suffix);
|
||||
String clerkToken = wxTokenService.createWxUserToken(clerkId);
|
||||
clerkUserInfoService.updateTokenById(clerkId, clerkToken);
|
||||
|
||||
when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
|
||||
.thenReturn(
|
||||
"https://oss.mock/apitest/media-draft.png",
|
||||
"https://oss.mock/apitest/media-approved.png");
|
||||
|
||||
String draftMediaId = uploadTinyPng("media-draft.png", clerkToken);
|
||||
String approvedMediaId = uploadTinyPng("media-approved.png", clerkToken);
|
||||
|
||||
List<PlayClerkMediaAssetEntity> assets = clerkMediaAssetService.lambdaQuery()
|
||||
.eq(PlayClerkMediaAssetEntity::getClerkId, clerkId)
|
||||
.eq(PlayClerkMediaAssetEntity::getUsage, "profile")
|
||||
.eq(PlayClerkMediaAssetEntity::getDeleted, false)
|
||||
.in(PlayClerkMediaAssetEntity::getMediaId, List.of(draftMediaId, approvedMediaId))
|
||||
.list();
|
||||
PlayClerkMediaAssetEntity draftAsset = assets.stream().filter(a -> draftMediaId.equals(a.getMediaId()))
|
||||
.findFirst().orElseThrow();
|
||||
PlayClerkMediaAssetEntity approvedAsset = assets.stream().filter(a -> approvedMediaId.equals(a.getMediaId()))
|
||||
.findFirst().orElseThrow();
|
||||
|
||||
draftAsset.setReviewState(ClerkMediaReviewState.DRAFT.getCode());
|
||||
approvedAsset.setReviewState(ClerkMediaReviewState.APPROVED.getCode());
|
||||
clerkMediaAssetService.updateBatchById(List.of(draftAsset, approvedAsset));
|
||||
|
||||
mockMvc.perform(get("/wx/clerk/media/list")
|
||||
.param("usage", "profile")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data[*].mediaId").value(containsInAnyOrder(draftMediaId)));
|
||||
|
||||
mockMvc.perform(get("/wx/clerk/media/approved")
|
||||
.param("usage", "profile")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data[*].mediaId").value(containsInAnyOrder(approvedMediaId)));
|
||||
}
|
||||
|
||||
private String ensureActiveClerk(String clerkId, String openId) {
|
||||
PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
|
||||
if (existing == null) {
|
||||
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
|
||||
entity.setId(clerkId);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setOpenid(openId);
|
||||
entity.setNickname("API Test Clerk Media");
|
||||
entity.setAvatar("https://example.com/avatar.png");
|
||||
entity.setSysUserId("");
|
||||
entity.setOnboardingState("1");
|
||||
entity.setListingState("1");
|
||||
entity.setClerkState("1");
|
||||
entity.setOnlineState("1");
|
||||
clerkUserInfoService.save(entity);
|
||||
return clerkId;
|
||||
}
|
||||
PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
|
||||
patch.setId(clerkId);
|
||||
patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
patch.setOpenid(openId);
|
||||
patch.setDeleted(Boolean.FALSE);
|
||||
patch.setSysUserId("");
|
||||
patch.setOnboardingState("1");
|
||||
patch.setListingState("1");
|
||||
patch.setClerkState("1");
|
||||
patch.setOnlineState("1");
|
||||
clerkUserInfoService.updateById(patch);
|
||||
return clerkId;
|
||||
}
|
||||
|
||||
private String uploadTinyPng(String filename, String clerkToken) throws Exception {
|
||||
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", baos);
|
||||
MockMultipartFile file = new MockMultipartFile("file", filename, "image/png", baos.toByteArray());
|
||||
|
||||
MvcResult result = mockMvc.perform(multipart("/wx/clerk/media/upload")
|
||||
.file(file)
|
||||
.param("usage", "profile")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
return OBJECT_MAPPER.readTree(result.getResponse().getContentAsString()).path("data").path("mediaId").asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.order.service.impl.PlayOrderInfoServiceImpl;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
|
||||
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.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
class WxClerkOrderCompleteApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelAdminInfoService personnelAdminInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService personnelGroupInfoService;
|
||||
|
||||
@MockBean
|
||||
private PlayOrderInfoServiceImpl orderInfoService;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeOrderRejectsWhenSysUserIdMissing__covers_CLK_017() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
String clerkId = "clerk-complete-nosys-" + IdUtils.getUuid().substring(0, 8);
|
||||
ensureClerk(clerkId, "openid-" + clerkId, "");
|
||||
String token = ensureClerkToken(clerkId);
|
||||
|
||||
String payload = "{\"orderId\":\"order-any\",\"remark\":\"\"}";
|
||||
mockMvc.perform(post("/wx/clerk/order/complete")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("账号未绑定系统用户,无法完成订单"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeOrderChoosesAdminOperatorType__covers_CLK_018() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
String sysUserId = "sys-admin-" + IdUtils.getUuid().substring(0, 8);
|
||||
PlayPersonnelAdminInfoEntity admin = new PlayPersonnelAdminInfoEntity();
|
||||
admin.setId("admin-" + IdUtils.getUuid().substring(0, 8));
|
||||
admin.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
admin.setSysUserId(sysUserId);
|
||||
admin.setAdminName("admin");
|
||||
admin.setAddTime(LocalDateTime.now());
|
||||
personnelAdminInfoService.save(admin);
|
||||
|
||||
String clerkId = "clerk-complete-admin-" + IdUtils.getUuid().substring(0, 8);
|
||||
ensureClerk(clerkId, "openid-" + clerkId, sysUserId);
|
||||
String token = ensureClerkToken(clerkId);
|
||||
|
||||
doNothing().when(orderInfoService).completeOrderByManagement(
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
|
||||
String orderId = "order-admin-" + IdUtils.getUuid().substring(0, 8);
|
||||
String payload = "{\"orderId\":\"" + orderId + "\",\"remark\":\"admin\"}";
|
||||
mockMvc.perform(post("/wx/clerk/order/complete")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("操作成功"));
|
||||
|
||||
ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> operatorCaptor = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> orderCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(orderInfoService).completeOrderByManagement(
|
||||
typeCaptor.capture(),
|
||||
operatorCaptor.capture(),
|
||||
orderCaptor.capture(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
assertThat(typeCaptor.getValue()).isEqualTo("2");
|
||||
assertThat(operatorCaptor.getValue()).isEqualTo(sysUserId);
|
||||
assertThat(orderCaptor.getValue()).isEqualTo(orderId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeOrderChoosesGroupLeaderOperatorType__covers_CLK_018() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
String sysUserId = "sys-leader-" + IdUtils.getUuid().substring(0, 8);
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId("group-" + IdUtils.getUuid().substring(0, 8));
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId(sysUserId);
|
||||
group.setGroupName("group");
|
||||
group.setLeaderName("leader");
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
personnelGroupInfoService.save(group);
|
||||
|
||||
String clerkId = "clerk-complete-leader-" + IdUtils.getUuid().substring(0, 8);
|
||||
ensureClerk(clerkId, "openid-" + clerkId, sysUserId);
|
||||
String token = ensureClerkToken(clerkId);
|
||||
|
||||
doNothing().when(orderInfoService).completeOrderByManagement(
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
|
||||
String orderId = "order-leader-" + IdUtils.getUuid().substring(0, 8);
|
||||
String payload = "{\"orderId\":\"" + orderId + "\",\"remark\":\"leader\"}";
|
||||
mockMvc.perform(post("/wx/clerk/order/complete")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("操作成功"));
|
||||
|
||||
ArgumentCaptor<String> typeCaptor = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> operatorCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(orderInfoService).completeOrderByManagement(
|
||||
typeCaptor.capture(),
|
||||
operatorCaptor.capture(),
|
||||
org.mockito.ArgumentMatchers.eq(orderId),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
assertThat(typeCaptor.getValue()).isEqualTo("3");
|
||||
assertThat(operatorCaptor.getValue()).isEqualTo(clerkId);
|
||||
}
|
||||
|
||||
private String ensureClerkToken(String clerkId) {
|
||||
String token = wxTokenService.createWxUserToken(clerkId);
|
||||
clerkUserInfoService.updateTokenById(clerkId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
private void ensureClerk(String clerkId, String openid, String sysUserId) {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
|
||||
entity.setId(clerkId);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setOpenid(openid);
|
||||
entity.setNickname("API Clerk " + clerkId);
|
||||
entity.setSysUserId(sysUserId);
|
||||
entity.setOnboardingState("1");
|
||||
entity.setListingState("1");
|
||||
entity.setClerkState("1");
|
||||
entity.setOnlineState("1");
|
||||
if (clerkUserInfoService.getById(clerkId) == null) {
|
||||
clerkUserInfoService.save(entity);
|
||||
} else {
|
||||
entity.setDeleted(Boolean.FALSE);
|
||||
clerkUserInfoService.updateById(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkWagesInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkWagesDetailsInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkWagesInfoService;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
class WxClerkWagesControllerApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkWagesInfoService wagesInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkWagesDetailsInfoService wagesDetailsInfoService;
|
||||
|
||||
private final java.util.List<String> orderIdsToCleanup = new java.util.ArrayList<>();
|
||||
private final java.util.List<String> wagesIdsToCleanup = new java.util.ArrayList<>();
|
||||
private String clerkToken;
|
||||
private String clerkId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String suffix = Long.toString(System.nanoTime(), 36);
|
||||
clerkId = "apitest-wage-clerk-" + suffix;
|
||||
String openId = "openid-" + clerkId;
|
||||
clerkToken = wxTokenService.createWxUserToken(clerkId);
|
||||
ensureActiveClerk(clerkId, openId, clerkToken);
|
||||
|
||||
wagesDetailsInfoService.lambdaUpdate()
|
||||
.eq(com.starry.admin.modules.clerk.module.entity.PlayClerkWagesDetailsInfoEntity::getClerkId,
|
||||
clerkId)
|
||||
.remove();
|
||||
wagesInfoService.lambdaUpdate()
|
||||
.eq(PlayClerkWagesInfoEntity::getClerkId, clerkId)
|
||||
.remove();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!orderIdsToCleanup.isEmpty()) {
|
||||
orderInfoService.removeByIds(orderIdsToCleanup);
|
||||
orderIdsToCleanup.clear();
|
||||
}
|
||||
if (!wagesIdsToCleanup.isEmpty()) {
|
||||
wagesInfoService.removeByIds(wagesIdsToCleanup);
|
||||
wagesIdsToCleanup.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryUnsettledWagesSumsOrdersForClerk__covers_WAGE_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String orderA = insertUnsettledOrder(new BigDecimal("10.00"), new BigDecimal("3.00"));
|
||||
String orderB = insertUnsettledOrder(new BigDecimal("20.50"), new BigDecimal("6.50"));
|
||||
orderIdsToCleanup.add(orderA);
|
||||
orderIdsToCleanup.add(orderB);
|
||||
|
||||
mockMvc.perform(get("/wx/wages/clerk/queryUnsettledWages")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.orderNumber").value(2))
|
||||
.andExpect(jsonPath("$.data.orderMoney").value(30.50))
|
||||
.andExpect(jsonPath("$.data.estimatedRevenue").value(9.50));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryCurrentPeriodWagesReturnsZerosWhenNoRow__covers_WAGE_002() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/wages/clerk/queryCurrentPeriodWages")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.totalMoney").value(0))
|
||||
.andExpect(jsonPath("$.data.orderWages.orderNumber").value(0))
|
||||
.andExpect(jsonPath("$.data.startCountDate").value(LocalDate.now().toString()))
|
||||
.andExpect(jsonPath("$.data.endCountDate").value(LocalDate.now().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryHistoricalWagesReturnsHardcodedPageMeta__covers_WAGE_003() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
insertHistoricalWages("apitest-wage-his-1", new BigDecimal("12.34"));
|
||||
insertHistoricalWages("apitest-wage-his-2", new BigDecimal("56.78"));
|
||||
|
||||
mockMvc.perform(post("/wx/wages/clerk/queryHistoricalWages")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.total").value(5))
|
||||
.andExpect(jsonPath("$.pageInfo.pageSize").value(10))
|
||||
.andExpect(jsonPath("$.pageInfo.totalPage").value(1))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data.length()").value(2));
|
||||
}
|
||||
|
||||
private String insertUnsettledOrder(BigDecimal finalAmount, BigDecimal estimatedRevenue) {
|
||||
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
|
||||
String id = "apitest-wage-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setAcceptBy(clerkId);
|
||||
entity.setOrderSettlementState("0");
|
||||
entity.setFinalAmount(finalAmount);
|
||||
entity.setEstimatedRevenue(estimatedRevenue);
|
||||
entity.setDeleted(false);
|
||||
orderInfoService.save(entity);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void insertHistoricalWages(String idPrefix, BigDecimal finalAmount) {
|
||||
PlayClerkWagesInfoEntity entity = new PlayClerkWagesInfoEntity();
|
||||
String id = idPrefix + "-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setHistoricalStatistics("1");
|
||||
entity.setStartCountDate(LocalDate.now().minusDays(7));
|
||||
entity.setEndCountDate(LocalDate.now().minusDays(1));
|
||||
entity.setSettlementDate(LocalDate.now().minusDays(1));
|
||||
entity.setOrderNumber(1);
|
||||
entity.setFinalAmount(finalAmount);
|
||||
entity.setEstimatedRevenue(finalAmount);
|
||||
entity.setCreatedTime(java.sql.Timestamp.valueOf(LocalDateTime.now()));
|
||||
entity.setUpdatedTime(java.sql.Timestamp.valueOf(LocalDateTime.now()));
|
||||
entity.setDeleted(false);
|
||||
wagesInfoService.save(entity);
|
||||
wagesIdsToCleanup.add(id);
|
||||
}
|
||||
|
||||
private void ensureActiveClerk(String clerkId, String openId, String token) {
|
||||
PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
|
||||
if (existing == null) {
|
||||
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
|
||||
entity.setId(clerkId);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setOpenid(openId);
|
||||
entity.setNickname("API Test Wages Clerk");
|
||||
entity.setAvatar("https://example.com/avatar.png");
|
||||
entity.setSysUserId("");
|
||||
entity.setOnboardingState("1");
|
||||
entity.setListingState("1");
|
||||
entity.setClerkState("1");
|
||||
entity.setOnlineState("1");
|
||||
entity.setToken(token);
|
||||
clerkUserInfoService.save(entity);
|
||||
return;
|
||||
}
|
||||
PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
|
||||
patch.setId(clerkId);
|
||||
patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
patch.setOpenid(openId);
|
||||
patch.setSysUserId("");
|
||||
patch.setOnboardingState("1");
|
||||
patch.setListingState("1");
|
||||
patch.setClerkState("1");
|
||||
patch.setOnlineState("1");
|
||||
patch.setDeleted(Boolean.FALSE);
|
||||
patch.setToken(token);
|
||||
clerkUserInfoService.updateById(patch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.common.oss.service.IOssFileService;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
class WxCommonControllerApiTest extends AbstractApiTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockBean
|
||||
private IOssFileService ossFileService;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void areaTreeReturnsTreeSchema__covers_COM_001() throws Exception {
|
||||
mockMvc.perform(get("/wx/common/area/tree")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingInfoReturnsGlobalRowRegardlessOfTenant__covers_COM_002() throws Exception {
|
||||
MvcResult first = mockMvc.perform(get("/wx/common/setting/info")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, "tenant-a-" + IdUtils.getUuid().substring(0, 6)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
JsonNode firstRoot = objectMapper.readTree(first.getResponse().getContentAsString(StandardCharsets.UTF_8));
|
||||
String firstId = firstRoot.path("data").path("id").asText();
|
||||
assertThat(firstId).isNotBlank();
|
||||
|
||||
MvcResult second = mockMvc.perform(get("/wx/common/setting/info")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, "tenant-b-" + IdUtils.getUuid().substring(0, 6)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
JsonNode secondRoot = objectMapper.readTree(second.getResponse().getContentAsString(StandardCharsets.UTF_8));
|
||||
String secondId = secondRoot.path("data").path("id").asText();
|
||||
assertThat(secondId).isNotEqualTo(firstId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileUploadReturnsOssUrl__covers_COM_003() throws Exception {
|
||||
String url = "https://oss.mock/apitest/common-upload.png";
|
||||
when(ossFileService.upload(any(), eq(ApiTestDataSeeder.DEFAULT_TENANT_ID), anyString()))
|
||||
.thenReturn(url);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"common-upload.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
new byte[] {0x01, 0x02, 0x03});
|
||||
|
||||
mockMvc.perform(multipart("/wx/common/file/upload")
|
||||
.file(file)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(url));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.weichat.service.WxAccessTokenService;
|
||||
import com.starry.admin.modules.weichat.utils.WxFileUtils;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.redis.RedisCache;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
class WxCommonControllerAudioUploadApiTest extends AbstractApiTest {
|
||||
|
||||
@MockBean
|
||||
private com.starry.admin.common.oss.service.IOssFileService ossFileService;
|
||||
|
||||
@MockBean
|
||||
private WxAccessTokenService wxAccessTokenService;
|
||||
|
||||
@MockBean
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Test
|
||||
void audioUploadRejectsBlankMediaId__covers_COM_004() throws Exception {
|
||||
SecurityUtils.setTenantId("tenant-apitest");
|
||||
|
||||
mockMvc.perform(get("/wx/common/audio/upload")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.param("mediaId", ""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void audioUploadDownloadsConvertsAndUploadsMp3__covers_COM_005() throws Exception {
|
||||
SecurityUtils.setTenantId("tenant-apitest");
|
||||
when(wxAccessTokenService.getAccessToken()).thenReturn("access-token");
|
||||
when(ossFileService.upload(any(), eq("tenant-apitest"), anyString())).thenReturn("https://oss.example/audio.mp3");
|
||||
|
||||
try (MockedStatic<WxFileUtils> mocked = Mockito.mockStatic(WxFileUtils.class)) {
|
||||
mocked.when(() -> WxFileUtils.getTemporaryMaterial(eq("access-token"), eq("media-1")))
|
||||
.thenReturn(new ByteArrayInputStream("amr-bytes".getBytes()));
|
||||
mocked.when(() -> WxFileUtils.audioConvert2Mp3(any(File.class), any(File.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
File target = invocation.getArgument(1);
|
||||
Files.write(target.toPath(), "mp3-bytes".getBytes());
|
||||
return null;
|
||||
});
|
||||
|
||||
mockMvc.perform(get("/wx/common/audio/upload")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.param("mediaId", "media-1")
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("https://oss.example/audio.mp3"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ class WxCouponControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void obtainCouponRejectsNonWhitelistCustomer() throws Exception {
|
||||
void obtainCouponReturnsNonEligibilityReasonWhenNonWhitelisted__covers_CP_002() throws Exception {
|
||||
ensureTenantContext();
|
||||
String couponId = newCouponId("whitelist");
|
||||
PlayCouponInfoEntity coupon = createBaseCoupon(couponId);
|
||||
@@ -123,7 +123,7 @@ class WxCouponControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryAllSkipsOfflineCouponsAndMarksObtainedOnes() throws Exception {
|
||||
void queryAllSkipsOfflineCouponsAndMarksObtainedOnes__covers_CP_004() throws Exception {
|
||||
ensureTenantContext();
|
||||
PlayCouponInfoEntity onlineCoupon = createBaseCoupon(newCouponId("online"));
|
||||
onlineCoupon.setCustomWhitelist(List.of(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID));
|
||||
@@ -164,7 +164,7 @@ class WxCouponControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByOrderFlagsOnlyEligibleCouponsAsAvailable() throws Exception {
|
||||
void queryByOrderFlagsOnlyEligibleCouponsAsAvailable__covers_CP_006() throws Exception {
|
||||
ensureTenantContext();
|
||||
PlayCouponInfoEntity eligible = createBaseCoupon(newCouponId("eligible"));
|
||||
eligible.setUseMinAmount(MINIMUM_USAGE_AMOUNT);
|
||||
@@ -200,23 +200,30 @@ class WxCouponControllerApiTest extends AbstractApiTest {
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
ArrayNode data = (ArrayNode) mapper.readTree(result.getResponse().getContentAsString()).path("data");
|
||||
ArrayNode data = (ArrayNode) mapper.readTree(result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8))
|
||||
.path("data");
|
||||
assertThat(data).isNotNull();
|
||||
assertThat(data.size()).isGreaterThanOrEqualTo(2);
|
||||
boolean foundEligibleDetail = false;
|
||||
boolean foundIneligibleDetail = false;
|
||||
for (JsonNode node : data) {
|
||||
if (eligible.getId().equals(node.path("couponId").asText())) {
|
||||
if (eligibleDetail.getId().equals(node.path("id").asText())) {
|
||||
foundEligibleDetail = true;
|
||||
assertThat(node.path("available").asText()).isEqualTo("1");
|
||||
assertThat(node.path("reasonForUnavailableUse").asText("")).isEmpty();
|
||||
}
|
||||
if (ineligible.getId().equals(node.path("couponId").asText())) {
|
||||
if (ineligibleDetail.getId().equals(node.path("id").asText())) {
|
||||
foundIneligibleDetail = true;
|
||||
assertThat(node.path("available").asText()).isEqualTo("0");
|
||||
assertThat(node.path("reasonForUnavailableUse").asText()).isEqualTo("订单类型不符合");
|
||||
}
|
||||
}
|
||||
assertThat(foundEligibleDetail).isTrue();
|
||||
assertThat(foundIneligibleDetail).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void obtainCouponSucceedsWhenEligible() throws Exception {
|
||||
void obtainCouponSucceedsWhenEligible__covers_CP_003() throws Exception {
|
||||
ensureTenantContext();
|
||||
PlayCouponInfoEntity coupon = createBaseCoupon(newCouponId("success"));
|
||||
couponInfoService.save(coupon);
|
||||
@@ -244,7 +251,7 @@ class WxCouponControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void obtainCouponHonorsPerUserLimit() throws Exception {
|
||||
void obtainCouponHonorsPerUserLimit__covers_CP_002() throws Exception {
|
||||
ensureTenantContext();
|
||||
PlayCouponInfoEntity coupon = createBaseCoupon(newCouponId("limit"));
|
||||
coupon.setClerkObtainedMaxQuantity(1);
|
||||
@@ -279,6 +286,121 @@ class WxCouponControllerApiTest extends AbstractApiTest {
|
||||
.andExpect(jsonPath("$.data.msg").value("优惠券已达到领取上限"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void obtainCouponRejectsEmptyId__covers_CP_001() throws Exception {
|
||||
ensureTenantContext();
|
||||
|
||||
mockMvc.perform(get("/wx/coupon/custom/obtainCoupon")
|
||||
.param("id", "")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("请求参数异常,优惠券ID不能为"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryAllHidesWhitelistCouponFromNonWhitelistCustomer__covers_CP_004() throws Exception {
|
||||
ensureTenantContext();
|
||||
|
||||
String couponId = newCouponId("qall-wl");
|
||||
PlayCouponInfoEntity coupon = createBaseCoupon(couponId);
|
||||
coupon.setClaimConditionType(CouponClaimConditionType.WHITELIST.code());
|
||||
coupon.setCustomWhitelist(List.of("other-customer"));
|
||||
couponInfoService.save(coupon);
|
||||
couponIds.add(coupon.getId());
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/wx/coupon/custom/queryAll")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode data = mapper.readTree(result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8))
|
||||
.path("data");
|
||||
assertThat(data).isNotNull();
|
||||
assertThat(data.isArray()).isTrue();
|
||||
for (JsonNode node : data) {
|
||||
assertThat(node.path("id").asText()).isNotEqualTo(coupon.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByOrderRejectsWhenClerkIdAndLevelIdBothEmpty__covers_CP_005() throws Exception {
|
||||
ensureTenantContext();
|
||||
|
||||
ObjectNode payload = mapper.createObjectNode();
|
||||
payload.put("commodityId", ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
payload.put("levelId", "");
|
||||
payload.put("clerkId", "");
|
||||
payload.put("placeType", OrderConstant.PlaceType.RANDOM.getCode());
|
||||
payload.put("commodityQuantity", 1);
|
||||
|
||||
mockMvc.perform(post("/wx/coupon/custom/queryByOrder")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("请求参数异常,店员ID不能为空,等级ID不能为空"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByOrderSwallowsBrokenCouponAndReturnsRemaining__covers_CP_007() throws Exception {
|
||||
ensureTenantContext();
|
||||
PlayCouponInfoEntity eligible = createBaseCoupon(newCouponId("swv"));
|
||||
eligible.setUseMinAmount(BigDecimal.ZERO);
|
||||
couponInfoService.save(eligible);
|
||||
couponIds.add(eligible.getId());
|
||||
|
||||
PlayCouponDetailsEntity eligibleDetail = createCouponDetail(eligible.getId(), CouponUseState.UNUSED);
|
||||
couponDetailsService.save(eligibleDetail);
|
||||
couponDetailIds.add(eligibleDetail.getId());
|
||||
|
||||
PlayCouponDetailsEntity brokenDetail = createCouponDetail(newCouponId("missing"), CouponUseState.UNUSED);
|
||||
couponDetailsService.save(brokenDetail);
|
||||
couponDetailIds.add(brokenDetail.getId());
|
||||
|
||||
ObjectNode payload = mapper.createObjectNode();
|
||||
payload.put("commodityId", ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
payload.put("levelId", ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
payload.put("clerkId", "");
|
||||
payload.put("placeType", OrderConstant.PlaceType.RANDOM.getCode());
|
||||
payload.put("commodityQuantity", 1);
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/wx/coupon/custom/queryByOrder")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode data = mapper.readTree(result.getResponse().getContentAsString()).path("data");
|
||||
assertThat(data.isArray()).isTrue();
|
||||
boolean foundEligibleDetail = false;
|
||||
boolean foundBrokenDetail = false;
|
||||
for (JsonNode node : data) {
|
||||
String returnedId = node.path("id").asText();
|
||||
if (eligibleDetail.getId().equals(returnedId)) {
|
||||
foundEligibleDetail = true;
|
||||
}
|
||||
if (brokenDetail.getId().equals(returnedId)) {
|
||||
foundBrokenDetail = true;
|
||||
}
|
||||
}
|
||||
assertThat(foundEligibleDetail).isTrue();
|
||||
assertThat(foundBrokenDetail).isFalse();
|
||||
}
|
||||
|
||||
private PlayCouponInfoEntity createBaseCoupon(String id) {
|
||||
PlayCouponInfoEntity coupon = new PlayCouponInfoEntity();
|
||||
coupon.setId(id);
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.custom.entity.PlayCustomFollowInfoEntity;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomLeaveMsgEntity;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomLeaveMsgService;
|
||||
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.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
class WxCustomControllerMiscApiTest extends AbstractApiTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomUserInfoService customUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomLeaveMsgService leaveMsgService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomFollowInfoService followInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryClerkDetailedByIdWorksWithoutLogin__covers_CUS_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/custom/queryClerkDetailedById")
|
||||
.param("id", ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByIdReturnsClerkStateWhenOpenidMatchesClerk__covers_CUS_002() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String customerId = "customer-openid-match-" + IdUtils.getUuid().substring(0, 8);
|
||||
String token = ensureCustomerWithOpenid(customerId, ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/custom/queryById")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.clerkState").value("1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateHideLevelStateIgnoresClientIdAndUsesSessionId__covers_CUS_003() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String sessionCustomerId = "customer-hide-session-" + IdUtils.getUuid().substring(0, 8);
|
||||
String otherCustomerId = "customer-hide-other-" + IdUtils.getUuid().substring(0, 8);
|
||||
String sessionToken = ensureCustomerWithOpenid(sessionCustomerId, "openid-" + sessionCustomerId);
|
||||
ensureCustomerWithOpenid(otherCustomerId, "openid-" + otherCustomerId);
|
||||
|
||||
customUserInfoService.lambdaUpdate()
|
||||
.eq(PlayCustomUserInfoEntity::getId, sessionCustomerId)
|
||||
.set(PlayCustomUserInfoEntity::getHideLevelState, "0")
|
||||
.update();
|
||||
customUserInfoService.lambdaUpdate()
|
||||
.eq(PlayCustomUserInfoEntity::getId, otherCustomerId)
|
||||
.set(PlayCustomUserInfoEntity::getHideLevelState, "0")
|
||||
.update();
|
||||
|
||||
String payload = "{\"id\":\"" + otherCustomerId + "\",\"hideLevelState\":\"1\"}";
|
||||
mockMvc.perform(post("/wx/custom/updateHideLevelState")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + sessionToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("成功"));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayCustomUserInfoEntity sessionAfter = customUserInfoService.getById(sessionCustomerId);
|
||||
PlayCustomUserInfoEntity otherAfter = customUserInfoService.getById(otherCustomerId);
|
||||
assertThat(sessionAfter.getHideLevelState()).isEqualTo("1");
|
||||
assertThat(otherAfter.getHideLevelState()).isEqualTo("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaveAddCreatesRowAndReturnsPinnedMessage__covers_CUS_015() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String customerId = "customer-leave-" + IdUtils.getUuid().substring(0, 8);
|
||||
String token = ensureCustomerWithOpenid(customerId, "openid-" + customerId);
|
||||
|
||||
String content = "leave-content-" + LocalDateTime.now();
|
||||
String payload = "{\"content\":\"" + content + "\",\"images\":[],\"remark\":\"\"}";
|
||||
|
||||
mockMvc.perform(post("/wx/custom/leave/add")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("取消成功"));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayCustomLeaveMsgEntity latest = leaveMsgService.lambdaQuery()
|
||||
.eq(PlayCustomLeaveMsgEntity::getCustomId, customerId)
|
||||
.orderByDesc(PlayCustomLeaveMsgEntity::getMsgTime)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
assertThat(latest).isNotNull();
|
||||
assertThat(latest.getContent()).isEqualTo(content);
|
||||
assertThat(latest.getImages()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaveQueryPermissionReturnsSchemaPinned__covers_CUS_016() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String customerId = "customer-leave-perm-" + IdUtils.getUuid().substring(0, 8);
|
||||
String token = ensureCustomerWithOpenid(customerId, "openid-" + customerId);
|
||||
|
||||
mockMvc.perform(get("/wx/custom/leave/queryPermission")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.permission").value(true))
|
||||
.andExpect(jsonPath("$.data.msg").value(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void followStateUpdateIsIdempotentAndCorrect__covers_CUS_017() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String customerId = "customer-follow-" + IdUtils.getUuid().substring(0, 8);
|
||||
String token = ensureCustomerWithOpenid(customerId, "openid-" + customerId);
|
||||
|
||||
String payload = "{\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\",\"followState\":\"1\"}";
|
||||
for (int i = 0; i < 2; i++) {
|
||||
mockMvc.perform(post("/wx/custom/followState/update")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("修改成功"));
|
||||
}
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
long rowCount = followInfoService.lambdaQuery()
|
||||
.eq(PlayCustomFollowInfoEntity::getCustomId, customerId)
|
||||
.eq(PlayCustomFollowInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.count();
|
||||
assertThat(rowCount).isEqualTo(1);
|
||||
PlayCustomFollowInfoEntity row = followInfoService.lambdaQuery()
|
||||
.eq(PlayCustomFollowInfoEntity::getCustomId, customerId)
|
||||
.eq(PlayCustomFollowInfoEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.one();
|
||||
assertThat(row.getFollowState()).isEqualTo("1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void followQueryByPageReturnsPagingSchema__covers_CUS_018() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String customerId = "customer-follow-page-" + IdUtils.getUuid().substring(0, 8);
|
||||
String token = ensureCustomerWithOpenid(customerId, "openid-" + customerId);
|
||||
|
||||
followInfoService.updateFollowState(customerId, ApiTestDataSeeder.DEFAULT_CLERK_ID, "1");
|
||||
|
||||
String payload = "{\"pageNum\":1,\"pageSize\":10,\"followState\":\"1\"}";
|
||||
MvcResult result = mockMvc.perform(post("/wx/custom/follow/queryByPage")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8));
|
||||
assertThat(root.path("data").isArray()).isTrue();
|
||||
assertThat(root.path("pageInfo").isObject()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void complaintAddRejectsNonPurchaser__covers_CUS_014() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String purchaserId = "cmpown-" + IdUtils.getUuid().substring(0, 8);
|
||||
ensureCustomerWithOpenid(purchaserId, "openid-" + purchaserId);
|
||||
|
||||
String otherCustomerId = "cmpoth-" + IdUtils.getUuid().substring(0, 8);
|
||||
String otherToken = ensureCustomerWithOpenid(otherCustomerId, "openid-" + otherCustomerId);
|
||||
|
||||
String orderId = "order-complaint-" + IdUtils.getUuid().substring(0, 8);
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setOrderStatus(OrderConstant.OrderStatus.PENDING.getCode());
|
||||
order.setOrderType(OrderConstant.OrderType.NORMAL.getCode());
|
||||
order.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
|
||||
order.setPurchaserBy(purchaserId);
|
||||
order.setWeiChatCode("wx-" + purchaserId);
|
||||
orderInfoService.save(order);
|
||||
|
||||
String payload = "{\"orderId\":\"" + orderId + "\",\"wxChatCode\":\"wx-complain\",\"complaintCon\":\"bad\","
|
||||
+ "\"images\":[]}";
|
||||
|
||||
mockMvc.perform(post("/wx/custom/order/complaint/add")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + otherToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("只有下单人才能投诉"));
|
||||
}
|
||||
|
||||
private String ensureCustomerWithOpenid(String id, String openid) {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayCustomUserInfoEntity entity = new PlayCustomUserInfoEntity();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setOpenid(openid);
|
||||
entity.setNickname("api-" + id);
|
||||
entity.setAccountBalance(new BigDecimal("0.00"));
|
||||
entity.setAccountState("1");
|
||||
entity.setSubscribeState("1");
|
||||
entity.setPurchaseState("1");
|
||||
entity.setMobilePhoneState("1");
|
||||
customUserInfoService.save(entity);
|
||||
|
||||
String token = wxTokenService.createWxUserToken(id);
|
||||
customUserInfoService.updateTokenById(id, token);
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ class WxCustomGiftOrderApiTest extends WxCustomOrderApiTestSupport {
|
||||
@Test
|
||||
// 测试用例:用户余额充足且携带有效登录态时,请求 /wx/custom/order/gift 下单指定礼物,
|
||||
// 期望生成已完成的礼物奖励订单、产生对应收益记录,同时校验用户/陪玩师礼物计数与账户余额随订单金额同步更新。
|
||||
void giftOrderCreatesCompletedRewardAndUpdatesGiftCounters() throws Exception {
|
||||
void giftOrderCreatesCompletedRewardAndUpdatesGiftCounters__covers_CUS_005() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
try {
|
||||
resetCustomerBalance();
|
||||
|
||||
@@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderEvaluateInfoService;
|
||||
@@ -17,6 +18,7 @@ import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -101,6 +103,60 @@ class WxCustomOrderEvaluationApiTest extends WxCustomOrderApiTestSupport {
|
||||
assertThat(data.path("evaluateCon").asText()).endsWith(evaluationSuffix);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluateAddRejectsNonPurchaser__covers_CUS_012() throws Exception {
|
||||
ensureTenantContext();
|
||||
String remark = "evaluate-nonpurchaser-" + LocalDateTime.now();
|
||||
String orderId = createRandomOrder(remark);
|
||||
|
||||
String otherCustomerId = "customer-eval-other-" + IdUtils.getUuid().substring(0, 8);
|
||||
PlayCustomUserInfoEntity other = new PlayCustomUserInfoEntity();
|
||||
other.setId(otherCustomerId);
|
||||
other.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
other.setOpenid("openid-" + otherCustomerId);
|
||||
other.setNickname("other");
|
||||
other.setAccountBalance(new BigDecimal("0.00"));
|
||||
other.setAccountState("1");
|
||||
other.setSubscribeState("1");
|
||||
other.setPurchaseState("1");
|
||||
other.setMobilePhoneState("1");
|
||||
customUserInfoService.save(other);
|
||||
String otherToken = wxTokenService.createWxUserToken(otherCustomerId);
|
||||
customUserInfoService.updateTokenById(otherCustomerId, otherToken);
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("orderId", orderId);
|
||||
payload.put("anonymous", "1");
|
||||
payload.put("evaluateLevel", 5);
|
||||
payload.put("evaluateCon", "bad");
|
||||
|
||||
mockMvc.perform(post("/wx/custom/order/evaluate/add")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + otherToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("只有下单人才能评价"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryEvaluationRejectsWhenNotEvaluated__covers_CUS_013() throws Exception {
|
||||
ensureTenantContext();
|
||||
String remark = "evaluate-missing-" + LocalDateTime.now();
|
||||
String orderId = createRandomOrder(remark);
|
||||
|
||||
mockMvc.perform(get("/wx/custom/order/evaluate/queryByOrderId")
|
||||
.param("id", orderId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("当前订单未评价"));
|
||||
}
|
||||
|
||||
private String createRandomOrder(String remark) throws Exception {
|
||||
ensureTenantContext();
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ class WxCustomOrderQueryApiTest extends WxCustomOrderApiTestSupport {
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByPageReturnsOnlyOrdersBelongingToCurrentCustomer() throws Exception {
|
||||
void queryByPageReturnsOnlyOrdersBelongingToCurrentCustomer__covers_CUS_009() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
try {
|
||||
String token = ensureCustomerToken();
|
||||
|
||||
@@ -18,6 +18,8 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
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.PlayOrderRefundInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelAdminInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelAdminInfoService;
|
||||
import com.starry.admin.modules.shop.module.constant.CouponUseState;
|
||||
@@ -71,8 +73,11 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private IPlayPersonnelAdminInfoService playPersonnelAdminInfoService;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private IPlayOrderRefundInfoService orderRefundInfoService;
|
||||
|
||||
@Test
|
||||
void randomOrderFailsWhenBalanceInsufficient() throws Exception {
|
||||
void randomOrderFailsWhenBalanceInsufficient__covers_CUS_008() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String remark = "API random insufficient " + IdUtils.getUuid();
|
||||
try {
|
||||
@@ -120,9 +125,10 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
|
||||
@Test
|
||||
// 测试用例:客户带随机下单请求命中默认等级与服务,接口应返回成功文案,
|
||||
// 并新增一条处于待接单状态的随机订单,金额、商品信息与 remark 与提交参数保持一致。
|
||||
void randomOrderCreatesPendingOrder() throws Exception {
|
||||
void randomOrderCreatesPendingOrder__covers_CUS_007() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
try {
|
||||
reset(wxCustomMpService, overdueOrderHandlerTask);
|
||||
resetCustomerBalance();
|
||||
String rawToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, rawToken);
|
||||
@@ -175,6 +181,104 @@ class WxCustomRandomOrderApiTest extends WxCustomOrderApiTestSupport {
|
||||
Assertions.assertThat(latest.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.PENDING.getCode());
|
||||
Assertions.assertThat(latest.getCommodityId()).isEqualTo(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
Assertions.assertThat(latest.getOrderMoney()).isNotNull();
|
||||
|
||||
verify(wxCustomMpService).sendCreateOrderMessageBatch(
|
||||
argThat(list -> list != null && list.stream()
|
||||
.anyMatch(item -> ApiTestDataSeeder.DEFAULT_CLERK_OPEN_ID.equals(item.getOpenid()))),
|
||||
eq(latest.getOrderNo()),
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
eq(latest.getCommodityName()),
|
||||
eq(latest.getId()),
|
||||
eq(latest.getPlaceType()),
|
||||
eq(latest.getRewardType()));
|
||||
verify(overdueOrderHandlerTask).enqueue(eq(latest.getId() + "_" + ApiTestDataSeeder.DEFAULT_TENANT_ID));
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void endOrderTransitionsFromInProgressToCompleted__covers_CUS_010() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
try {
|
||||
resetCustomerBalance();
|
||||
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
|
||||
String clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
|
||||
String remark = "API random end " + IdUtils.getUuid();
|
||||
String orderId = placeRandomOrder(remark, customerToken);
|
||||
|
||||
ensureTenantContext();
|
||||
mockMvc.perform(get("/wx/clerk/order/accept")
|
||||
.param("id", orderId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
ensureTenantContext();
|
||||
mockMvc.perform(get("/wx/clerk/order/start")
|
||||
.param("id", orderId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
ensureTenantContext();
|
||||
mockMvc.perform(get("/wx/custom/order/end")
|
||||
.param("id", orderId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("成功"));
|
||||
|
||||
ensureTenantContext();
|
||||
PlayOrderInfoEntity updated = playOrderInfoService.selectOrderInfoById(orderId);
|
||||
Assertions.assertThat(updated.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.COMPLETED.getCode());
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationCreatesRefundRecordAndIgnoresImages__covers_CUS_011() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
try {
|
||||
resetCustomerBalance();
|
||||
String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken);
|
||||
|
||||
String remark = "API random cancel record " + IdUtils.getUuid();
|
||||
String orderId = placeRandomOrder(remark, customerToken);
|
||||
|
||||
String cancelPayload = "{\"orderId\":\"" + orderId + "\",\"refundReason\":\"测试取消\","
|
||||
+ "\"images\":[\"https://img.example/a.png\",\"https://img.example/b.png\"]}";
|
||||
mockMvc.perform(post("/wx/custom/order/cancellation")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(cancelPayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("取消成功"));
|
||||
|
||||
ensureTenantContext();
|
||||
PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(orderId);
|
||||
Assertions.assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
|
||||
Assertions.assertThat(cancelled.getRefundReason()).isNull();
|
||||
|
||||
ensureTenantContext();
|
||||
PlayOrderRefundInfoEntity refund = orderRefundInfoService.selectPlayOrderRefundInfoByOrderId(orderId);
|
||||
Assertions.assertThat(refund).isNotNull();
|
||||
Assertions.assertThat(refund.getRefundReason()).isEqualTo("测试取消");
|
||||
Assertions.assertThat(refund.getImages()).isNull();
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class WxCustomRewardOrderApiTest extends WxCustomOrderApiTestSupport {
|
||||
@Test
|
||||
// 测试用例:客户指定打赏金额下单时,应即时扣减账户余额、生成已完成的打赏订单并同步收益记录,
|
||||
// 同时校验订单归属陪玩师正确且金额与输入一致,确保余额打赏流程闭环。
|
||||
void rewardOrderConsumesBalanceAndGeneratesEarnings() throws Exception {
|
||||
void rewardOrderConsumesBalanceAndGeneratesEarnings__covers_CUS_004() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
try {
|
||||
resetCustomerBalance();
|
||||
|
||||
@@ -358,7 +358,7 @@ class WxCustomSpecifiedOrderApiTest extends WxCustomOrderApiTestSupport {
|
||||
@Test
|
||||
// 测试用例:客户携带指定陪玩师和服务下单时,接口需返回成功,并生成待支付状态的指定订单,
|
||||
// 验证订单金额与种子服务价格一致、陪玩师被正确指派,同时触发微信创建订单通知。
|
||||
void specifiedOrderCreatesPendingOrder() throws Exception {
|
||||
void specifiedOrderCreatesPendingOrder__covers_CUS_006() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
try {
|
||||
resetCustomerBalance();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@TestPropertySource(properties = "test.auth.secret=apitest-secret")
|
||||
class WxOauthAdminTestAuthApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
|
||||
private static final String TEST_AUTH_SECRET = "apitest-secret";
|
||||
|
||||
@Test
|
||||
void adminLoginByUsernameRejectsWithoutSecretHeader() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/admin/loginByUsername")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{" +
|
||||
"\"userName\":\"" + ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME + "\"," +
|
||||
"\"passWord\":\"apitest-secret\"," +
|
||||
"\"tenantKey\":\"" + ApiTestDataSeeder.DEFAULT_TENANT_KEY + "\"" +
|
||||
"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminLoginByUsernameReturnsTokenWhenSecretHeaderValid() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/admin/loginByUsername")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.header(TEST_AUTH_HEADER, TEST_AUTH_SECRET)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{" +
|
||||
"\"userName\":\"" + ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME + "\"," +
|
||||
"\"passWord\":\"apitest-secret\"," +
|
||||
"\"tenantKey\":\"" + ApiTestDataSeeder.DEFAULT_TENANT_KEY + "\"" +
|
||||
"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tokenHead").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.token").isNotEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.redis.RedisCache;
|
||||
import me.chanjar.weixin.common.bean.WxJsapiSignature;
|
||||
import me.chanjar.weixin.common.bean.WxOAuth2UserInfo;
|
||||
import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
|
||||
import me.chanjar.weixin.common.service.WxOAuth2Service;
|
||||
import me.chanjar.weixin.mp.api.WxMpService;
|
||||
import me.chanjar.weixin.mp.bean.result.WxMpUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
class WxOauthControllerApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomUserInfoService customUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private WxMpService wxMpService;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Test
|
||||
void getConfigAddressUsesDefaultWhenUrlMissing__covers_OAUTH_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
when(wxMpService.createJsapiSignature(anyString())).thenReturn(new WxJsapiSignature());
|
||||
|
||||
mockMvc.perform(post("/wx/oauth2/getConfigAddress")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"url\":\"\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigAddressUsesProvidedUrlWhenPresent__covers_OAUTH_002() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
when(wxMpService.createJsapiSignature(anyString())).thenReturn(new WxJsapiSignature());
|
||||
|
||||
mockMvc.perform(post("/wx/oauth2/getConfigAddress")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"url\":\"https://example.com/custom\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClerkLoginAddressBuildsAuthorizationUrl__covers_OAUTH_003() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
when(wxMpService.getOAuth2Service().buildAuthorizationUrl(anyString(), anyString(), anyString()))
|
||||
.thenReturn("https://wx.example/auth?scope=snsapi_userinfo");
|
||||
|
||||
mockMvc.perform(post("/wx/oauth2/getClerkLoginAddress")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"url\":\"https://example.com/callback\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("https://wx.example/auth?scope=snsapi_userinfo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCustomLoginAddressBuildsAuthorizationUrl__covers_OAUTH_004() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
when(wxMpService.getOAuth2Service().buildAuthorizationUrl(anyString(), anyString(), anyString()))
|
||||
.thenReturn("https://wx.example/auth?scope=snsapi_userinfo");
|
||||
|
||||
mockMvc.perform(post("/wx/oauth2/getCustomLoginAddress")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"url\":\"https://example.com/callback\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("https://wx.example/auth?scope=snsapi_userinfo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customLoginPersistsTokenAndReturnsPayload__covers_OAUTH_005() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
WxOAuth2AccessToken token = new WxOAuth2AccessToken();
|
||||
token.setOpenId(ApiTestDataSeeder.DEFAULT_CUSTOMER_OPEN_ID);
|
||||
WxOAuth2Service oAuth2Service = wxMpService.getOAuth2Service();
|
||||
Mockito.doReturn(token).when(oAuth2Service).getAccessToken(anyString());
|
||||
|
||||
WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
|
||||
userInfo.setOpenid(ApiTestDataSeeder.DEFAULT_CUSTOMER_OPEN_ID);
|
||||
userInfo.setNickname("API Test Customer");
|
||||
userInfo.setHeadImgUrl("https://example.com/avatar.png");
|
||||
Mockito.doReturn(userInfo).when(oAuth2Service).getUserInfo(eq(token), eq(null));
|
||||
|
||||
mockMvc.perform(post("/wx/oauth2/custom/login")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"code\":\"apitest-code\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tokenValue").isString())
|
||||
.andExpect(jsonPath("$.data.tokenName").value(Constants.CUSTOM_USER_LOGIN_TOKEN));
|
||||
|
||||
PlayCustomUserInfoEntity customer = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
if (customer == null || customer.getToken() == null || customer.getToken().isEmpty()) {
|
||||
throw new AssertionError("Expected customer token to be persisted after login");
|
||||
}
|
||||
Object cachedTenantId = redisCache.getCacheObject("TENANT_INFO:" + ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
if (!ApiTestDataSeeder.DEFAULT_TENANT_ID.equals(cachedTenantId)) {
|
||||
throw new AssertionError("Expected Redis TENANT_INFO to be cached after login");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void customLoginReturnsUnauthorizedWhenWxOauthFails__covers_OAUTH_006() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
WxOAuth2Service oAuth2Service = wxMpService.getOAuth2Service();
|
||||
Mockito.doThrow(new RuntimeException("wx-fail")).when(oAuth2Service).getAccessToken(eq("fail-code"));
|
||||
|
||||
mockMvc.perform(post("/wx/oauth2/custom/login")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"code\":\"fail-code\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(401));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customLogoutInvalidatesToken__covers_OAUTH_008() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
|
||||
|
||||
mockMvc.perform(get("/wx/oauth2/custom/logout")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
PlayCustomUserInfoEntity customer = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
if (customer == null) {
|
||||
throw new AssertionError("Customer missing");
|
||||
}
|
||||
if (!"empty".equals(customer.getToken())) {
|
||||
throw new AssertionError("Expected token to be invalidated to 'empty'");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLoginPersistsTokenAndCachesTenant__covers_OAUTH_007() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
String clerkId = "clerk-oauth-apitest";
|
||||
String clerkOpenId = "openid-clerk-oauth-apitest";
|
||||
|
||||
PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
|
||||
if (existing == null) {
|
||||
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
|
||||
entity.setId(clerkId);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setOpenid(clerkOpenId);
|
||||
entity.setNickname("API Test Clerk OAuth");
|
||||
entity.setAvatar("https://example.com/avatar.png");
|
||||
entity.setSysUserId("");
|
||||
entity.setOnboardingState("1");
|
||||
entity.setListingState("1");
|
||||
entity.setClerkState("1");
|
||||
entity.setOnlineState("1");
|
||||
clerkUserInfoService.save(entity);
|
||||
} else {
|
||||
PlayClerkUserInfoEntity patch = new PlayClerkUserInfoEntity();
|
||||
patch.setId(clerkId);
|
||||
patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
patch.setOpenid(clerkOpenId);
|
||||
patch.setAvatar("https://example.com/avatar.png");
|
||||
patch.setSysUserId("");
|
||||
patch.setOnboardingState("1");
|
||||
patch.setListingState("1");
|
||||
patch.setClerkState("1");
|
||||
patch.setOnlineState("1");
|
||||
patch.setDeleted(Boolean.FALSE);
|
||||
clerkUserInfoService.updateById(patch);
|
||||
}
|
||||
|
||||
WxOAuth2AccessToken token = new WxOAuth2AccessToken();
|
||||
token.setOpenId(clerkOpenId);
|
||||
WxOAuth2Service oAuth2Service = wxMpService.getOAuth2Service();
|
||||
Mockito.doReturn(token).when(oAuth2Service).getAccessToken(anyString());
|
||||
|
||||
WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
|
||||
userInfo.setOpenid(clerkOpenId);
|
||||
userInfo.setNickname("API Test Clerk");
|
||||
userInfo.setHeadImgUrl("https://example.com/avatar.png");
|
||||
Mockito.doReturn(userInfo).when(oAuth2Service).getUserInfo(eq(token), eq(null));
|
||||
|
||||
mockMvc.perform(post("/wx/oauth2/clerk/login")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"code\":\"apitest-code\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tokenValue").isString())
|
||||
.andExpect(jsonPath("$.data.tokenName").value(Constants.CLERK_USER_LOGIN_TOKEN))
|
||||
.andExpect(jsonPath("$.data.pcData.token").value(""))
|
||||
.andExpect(jsonPath("$.data.pcData.role").value(""));
|
||||
|
||||
PlayClerkUserInfoEntity clerk = clerkUserInfoService.selectById(clerkId);
|
||||
if (clerk == null || clerk.getToken() == null || clerk.getToken().isEmpty() || "empty".equals(clerk.getToken())) {
|
||||
throw new AssertionError("Expected clerk token to be persisted after login");
|
||||
}
|
||||
Object cachedTenantId = redisCache.getCacheObject("TENANT_INFO:" + clerkId);
|
||||
if (!ApiTestDataSeeder.DEFAULT_TENANT_ID.equals(cachedTenantId)) {
|
||||
throw new AssertionError("Expected clerk TENANT_INFO to be cached after login");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLogoutInvalidatesToken__covers_OAUTH_007() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, token);
|
||||
|
||||
mockMvc.perform(get("/wx/oauth2/clerk/logout")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
PlayClerkUserInfoEntity clerk = clerkUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
if (clerk == null) {
|
||||
throw new AssertionError("Clerk missing");
|
||||
}
|
||||
if (!"empty".equals(clerk.getToken())) {
|
||||
throw new AssertionError("Expected clerk token to be invalidated to 'empty'");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkSubscribeReturnsBoolean__covers_OAUTH_011() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
|
||||
|
||||
WxMpUser wxMpUser = new WxMpUser();
|
||||
wxMpUser.setSubscribe(true);
|
||||
when(wxMpService.getUserService().userInfo(ApiTestDataSeeder.DEFAULT_CUSTOMER_OPEN_ID)).thenReturn(wxMpUser);
|
||||
|
||||
mockMvc.perform(get("/wx/oauth2/checkSubscribe")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@TestPropertySource(properties = "test.auth.secret=apitest-secret")
|
||||
class WxOauthE2eSeedOrderApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
|
||||
private static final String TEST_AUTH_SECRET = "apitest-secret";
|
||||
|
||||
@Test
|
||||
void seedOrderRejectsWithoutSecretHeader() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/e2e/seed/order")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedOrderReturnsFixtureWhenSecretHeaderValid() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/e2e/seed/order")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.header(TEST_AUTH_HEADER, TEST_AUTH_SECRET)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tenantKey").value(ApiTestDataSeeder.DEFAULT_TENANT_KEY))
|
||||
.andExpect(jsonPath("$.data.customerId").value(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID))
|
||||
.andExpect(jsonPath("$.data.customerNickname").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.clerkId").value(ApiTestDataSeeder.DEFAULT_CLERK_ID))
|
||||
.andExpect(jsonPath("$.data.clerkNickname").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.clerkLevelId").value(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID))
|
||||
.andExpect(jsonPath("$.data.clerkSex").value("2"))
|
||||
.andExpect(jsonPath("$.data.commodityId").value(ApiTestDataSeeder.DEFAULT_COMMODITY_ID))
|
||||
.andExpect(jsonPath("$.data.giftId").value(ApiTestDataSeeder.DEFAULT_GIFT_ID));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@TestPropertySource(properties = "test.auth.secret=apitest-secret")
|
||||
class WxOauthE2eSeedWageAdjustmentApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
|
||||
private static final String TEST_AUTH_SECRET = "apitest-secret";
|
||||
|
||||
@Test
|
||||
void seedWageAdjustmentRejectsWithoutSecretHeader() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/e2e/seed/wage-adjustment")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedWageAdjustmentReturnsFixtureWhenSecretHeaderValid() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/e2e/seed/wage-adjustment")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.header(TEST_AUTH_HEADER, TEST_AUTH_SECRET)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tenantKey").value(ApiTestDataSeeder.DEFAULT_TENANT_KEY))
|
||||
.andExpect(jsonPath("$.data.adminUserName").value(ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME))
|
||||
.andExpect(jsonPath("$.data.clerkId").value(ApiTestDataSeeder.DEFAULT_CLERK_ID))
|
||||
.andExpect(jsonPath("$.data.beginTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.endTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.baseAmount").value("150.00"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@TestPropertySource(properties = "test.auth.secret=apitest-secret")
|
||||
class WxOauthTestAuthApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
|
||||
private static final String TEST_AUTH_SECRET = "apitest-secret";
|
||||
private static final String DEV_FIXED_CLERK_ID = "a4471ef596a1";
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
ensureDevFixedClerkExists();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLoginByIdRejectsWithoutSecretHeader() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/clerk/loginById")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"code\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLoginByIdReturnsTokenWhenSecretHeaderValid() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/clerk/loginById")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.header(TEST_AUTH_HEADER, TEST_AUTH_SECRET)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"code\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tokenName").value(Constants.CLERK_USER_LOGIN_TOKEN))
|
||||
.andExpect(jsonPath("$.data.tokenValue").value(org.hamcrest.Matchers.startsWith(Constants.TOKEN_PREFIX)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLoginDevRejectsWithoutSecretHeader() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/clerk/login/dev")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"code\":\"test\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLoginDevReturnsTokenWhenSecretHeaderValid() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/clerk/login/dev")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.header(TEST_AUTH_HEADER, TEST_AUTH_SECRET)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"code\":\"test\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tokenName").value(Constants.CLERK_USER_LOGIN_TOKEN))
|
||||
.andExpect(jsonPath("$.data.tokenValue").value(org.hamcrest.Matchers.startsWith(Constants.TOKEN_PREFIX)));
|
||||
}
|
||||
|
||||
private void ensureDevFixedClerkExists() {
|
||||
PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(DEV_FIXED_CLERK_ID);
|
||||
if (existing != null) {
|
||||
return;
|
||||
}
|
||||
PlayClerkUserInfoEntity template = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
if (template == null) {
|
||||
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
|
||||
entity.setId(DEV_FIXED_CLERK_ID);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setOpenid("openid-dev-" + IdUtils.getUuid());
|
||||
entity.setNickname("Dev Login Clerk");
|
||||
entity.setAvatar("https://example.com/avatar.png");
|
||||
entity.setSysUserId("");
|
||||
entity.setOnboardingState("1");
|
||||
entity.setListingState("1");
|
||||
entity.setClerkState("1");
|
||||
entity.setOnlineState("1");
|
||||
entity.setToken("empty");
|
||||
entity.setDeleted(Boolean.FALSE);
|
||||
clerkUserInfoService.save(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
|
||||
entity.setId(DEV_FIXED_CLERK_ID);
|
||||
entity.setTenantId(template.getTenantId());
|
||||
entity.setOpenid("openid-dev-" + IdUtils.getUuid());
|
||||
entity.setNickname(template.getNickname());
|
||||
entity.setAvatar(template.getAvatar());
|
||||
entity.setSysUserId(template.getSysUserId());
|
||||
entity.setOnboardingState("1");
|
||||
entity.setListingState("1");
|
||||
entity.setClerkState("1");
|
||||
entity.setOnlineState("1");
|
||||
entity.setToken("empty");
|
||||
entity.setDeleted(Boolean.FALSE);
|
||||
clerkUserInfoService.save(entity);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.common.exception.ServiceException;
|
||||
import com.starry.admin.common.task.OverdueOrderHandlerTask;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
@@ -125,7 +126,7 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateContinuationRequestIsRejected() throws Exception {
|
||||
void continueRejectsSecondContinuationRequest__covers_ORD_002() throws Exception {
|
||||
String marker = "continue-" + LocalDateTime.now().toString();
|
||||
String orderId = createRandomOrder(marker);
|
||||
|
||||
@@ -168,7 +169,36 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
|
||||
}
|
||||
|
||||
@Test
|
||||
void randomOrderAcceptedByAnotherClerkHidesSensitiveFields() throws Exception {
|
||||
void continueRejectsNonOwnerClerk__covers_ORD_001() throws Exception {
|
||||
String marker = "continue-non-owner-" + LocalDateTime.now();
|
||||
String orderId = createRandomOrder(marker);
|
||||
|
||||
ensureTenantContext();
|
||||
playOrderInfoService.lambdaUpdate()
|
||||
.eq(PlayOrderInfoEntity::getId, orderId)
|
||||
.set(PlayOrderInfoEntity::getAcceptBy, OTHER_CLERK_ID)
|
||||
.set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode())
|
||||
.update();
|
||||
|
||||
ArrayNode images = mapper.createArrayNode().add("https://example.com/proof.png");
|
||||
ObjectNode payload = mapper.createObjectNode()
|
||||
.put("orderId", orderId)
|
||||
.put("remark", "加场申请")
|
||||
.set("images", images);
|
||||
|
||||
mockMvc.perform(post("/wx/order/clerk/continue")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("非本人订单;无法续单"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void randomOrderAcceptedByAnotherClerkHidesSensitiveFields__covers_ORD_003() throws Exception {
|
||||
String marker = PRIVACY_MARKER_PREFIX + "accepted-" + LocalDateTime.now();
|
||||
String orderId = createRandomOrder(marker);
|
||||
|
||||
@@ -197,6 +227,87 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
|
||||
assertThat(data.path("customAvatar").asText()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateReviewStateRejectsWhenAlreadyProcessed__covers_ORD_004() throws Exception {
|
||||
ensureTenantContext();
|
||||
PlayOrderContinueInfoEntity entity = new PlayOrderContinueInfoEntity();
|
||||
entity.setId("cont-processed-" + java.util.UUID.randomUUID().toString().substring(0, 8));
|
||||
entity.setOrderId("order-" + java.util.UUID.randomUUID().toString().substring(0, 8));
|
||||
entity.setOrderNo("ORDER-" + java.util.UUID.randomUUID().toString().substring(0, 6));
|
||||
entity.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
|
||||
entity.setOrderMoney(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
|
||||
entity.setFinalAmount(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
|
||||
entity.setContinueMsg("processed");
|
||||
entity.setReviewedRequired("1");
|
||||
entity.setReviewedState("1");
|
||||
entity.setContinueTime(LocalDateTime.now().minusMinutes(1));
|
||||
orderContinueInfoService.create(entity);
|
||||
|
||||
String payload = "{\"id\":\"" + entity.getId() + "\",\"reviewState\":\"1\",\"remark\":\"ok\"}";
|
||||
mockMvc.perform(post("/wx/order/custom/updateReviewState")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("续单已处理"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void continueListByPageForcesCustomIdToSession__covers_ORD_005() throws Exception {
|
||||
ensureTenantContext();
|
||||
String ownId = "cont-own-" + java.util.UUID.randomUUID().toString().substring(0, 8);
|
||||
PlayOrderContinueInfoEntity own = new PlayOrderContinueInfoEntity();
|
||||
own.setId(ownId);
|
||||
own.setOrderId("order-own-" + java.util.UUID.randomUUID().toString().substring(0, 8));
|
||||
own.setOrderNo("OWN-" + java.util.UUID.randomUUID().toString().substring(0, 6));
|
||||
own.setCustomId(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
own.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
own.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
|
||||
own.setOrderMoney(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
|
||||
own.setFinalAmount(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
|
||||
own.setReviewedRequired("1");
|
||||
own.setReviewedState("0");
|
||||
own.setContinueTime(LocalDateTime.now().minusMinutes(1));
|
||||
orderContinueInfoService.create(own);
|
||||
|
||||
String otherId = "cont-other-" + java.util.UUID.randomUUID().toString().substring(0, 8);
|
||||
PlayOrderContinueInfoEntity other = new PlayOrderContinueInfoEntity();
|
||||
other.setId(otherId);
|
||||
other.setOrderId("order-other-" + java.util.UUID.randomUUID().toString().substring(0, 8));
|
||||
other.setOrderNo("OTHER-" + java.util.UUID.randomUUID().toString().substring(0, 6));
|
||||
other.setCustomId("other-customer");
|
||||
other.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
other.setPlaceType(OrderConstant.PlaceType.RANDOM.getCode());
|
||||
other.setOrderMoney(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
|
||||
other.setFinalAmount(ApiTestDataSeeder.DEFAULT_COMMODITY_PRICE);
|
||||
other.setReviewedRequired("1");
|
||||
other.setReviewedState("0");
|
||||
other.setContinueTime(LocalDateTime.now().minusMinutes(1));
|
||||
orderContinueInfoService.create(other);
|
||||
|
||||
String payload = "{\"pageNum\":1,\"pageSize\":20,\"customId\":\"other-customer\"}";
|
||||
MvcResult result = mockMvc.perform(post("/wx/order/custom/continueListByPage")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode records = mapper.readTree(result.getResponse().getContentAsString()).path("data");
|
||||
assertThat(records.isArray()).isTrue();
|
||||
assertThat(records)
|
||||
.anyMatch(node -> ownId.equals(node.path("id").asText()))
|
||||
.noneMatch(node -> otherId.equals(node.path("id").asText()));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("randomOrderMaskingCases")
|
||||
void selectRandomOrderByIdAppliesPrivacyRules(OrderStatus status,
|
||||
@@ -334,7 +445,7 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByIdForRandomOrderAcceptedByAnotherClerkHidesCustomerInfoAndOrderStatus() throws Exception {
|
||||
void queryByIdForRandomOrderAcceptedByAnotherClerkHidesCustomerInfoAndOrderStatus__covers_CLK_013() throws Exception {
|
||||
String marker = "random-other-clerk-" + LocalDateTime.now();
|
||||
String orderId = createRandomOrder(marker);
|
||||
|
||||
@@ -366,6 +477,103 @@ class WxOrderInfoControllerApiTest extends WxCustomOrderApiTestSupport {
|
||||
assertThat(data.path("customAvatar").asText()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByIdForCancelledOrderClearsWeiChatCode__covers_CLK_014() throws Exception {
|
||||
String marker = "cancelled-weiChatCode-" + LocalDateTime.now();
|
||||
String orderId = createRandomOrder(marker);
|
||||
|
||||
ensureTenantContext();
|
||||
playOrderInfoService.lambdaUpdate()
|
||||
.eq(PlayOrderInfoEntity::getId, orderId)
|
||||
.set(PlayOrderInfoEntity::getPlaceType, OrderConstant.PlaceType.SPECIFIED.getCode())
|
||||
.set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.CANCELLED.getCode())
|
||||
.update();
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/wx/clerk/order/queryById")
|
||||
.param("id", orderId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode data = mapper.readTree(result.getResponse().getContentAsString()).path("data");
|
||||
assertThat(data.path("orderStatus").asText()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
|
||||
assertThat(data.path("weiChatCode").asText()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptOrderRequiresSubscribe__covers_CLK_015() throws Exception {
|
||||
String orderId = createRandomOrder("accept-subscribe-" + LocalDateTime.now());
|
||||
|
||||
Mockito.doThrow(new ServiceException("请先关注公众号然后再来使用系统~"))
|
||||
.when(wxCustomMpService)
|
||||
.checkSubscribeThrowsExp(Mockito.anyString(), Mockito.anyString());
|
||||
|
||||
mockMvc.perform(get("/wx/clerk/order/accept")
|
||||
.param("id", orderId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("请先关注公众号然后再来使用系统~"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startOrderTransitionsState__covers_CLK_016() throws Exception {
|
||||
String orderId = createRandomOrder("start-" + LocalDateTime.now());
|
||||
|
||||
ensureTenantContext();
|
||||
playOrderInfoService.lambdaUpdate()
|
||||
.eq(PlayOrderInfoEntity::getId, orderId)
|
||||
.set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode())
|
||||
.update();
|
||||
|
||||
mockMvc.perform(get("/wx/clerk/order/start")
|
||||
.param("id", orderId)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("成功"));
|
||||
|
||||
ensureTenantContext();
|
||||
PlayOrderInfoEntity updated = playOrderInfoService.selectOrderInfoById(orderId);
|
||||
assertThat(updated.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.IN_PROGRESS.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkCancellationUpdatesOrderState__covers_CLK_019() throws Exception {
|
||||
String orderId = createRandomOrder("clerk-cancel-" + LocalDateTime.now());
|
||||
|
||||
ensureTenantContext();
|
||||
playOrderInfoService.lambdaUpdate()
|
||||
.eq(PlayOrderInfoEntity::getId, orderId)
|
||||
.set(PlayOrderInfoEntity::getAcceptBy, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.set(PlayOrderInfoEntity::getOrderStatus, OrderConstant.OrderStatus.ACCEPTED.getCode())
|
||||
.update();
|
||||
|
||||
String payload = "{\"orderId\":\"" + orderId + "\",\"refundReason\":\"clerk cancel\",\"images\":[\"https://img.example/1.png\"]}";
|
||||
mockMvc.perform(post("/wx/clerk/order/cancellation")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").value("成功"));
|
||||
|
||||
ensureTenantContext();
|
||||
PlayOrderInfoEntity cancelled = playOrderInfoService.selectOrderInfoById(orderId);
|
||||
assertThat(cancelled.getOrderStatus()).isEqualTo(OrderConstant.OrderStatus.CANCELLED.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryByIdForNonRandomOrderAcceptedByAnotherClerkDoesNotMaskStatus() throws Exception {
|
||||
String marker = "specified-other-clerk-" + LocalDateTime.now();
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.github.binarywang.wxpay.bean.profitsharing.ProfitSharingResult;
|
||||
import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest;
|
||||
import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderResult;
|
||||
import com.github.binarywang.wxpay.service.WxPayService;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.common.exception.ServiceException;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
|
||||
import com.starry.admin.modules.system.service.impl.SysTenantServiceImpl;
|
||||
import com.starry.admin.modules.weichat.service.WxCustomMpService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import java.math.BigDecimal;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
class WxPayControllerApiTest extends AbstractApiTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomUserInfoService customUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayCustomLevelInfoService customLevelInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private SysTenantServiceImpl tenantService;
|
||||
|
||||
@MockBean
|
||||
private WxCustomMpService wxCustomMpService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCustomPaymentAmountRejectsEmptyMoney__covers_PAY_001() throws Exception {
|
||||
String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
|
||||
|
||||
mockMvc.perform(get("/wx/pay/custom/getCustomPaymentAmount")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.param("money", ""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCustomPaymentAmountReturnsDiscountedAmount__covers_PAY_002() throws Exception {
|
||||
String levelId = "lvl-custom-apitest";
|
||||
PlayCustomLevelInfoEntity level = customLevelInfoService.getById(levelId);
|
||||
if (level == null) {
|
||||
level = new PlayCustomLevelInfoEntity();
|
||||
level.setId(levelId);
|
||||
level.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
level.setName("API Test Level");
|
||||
level.setLevel(1);
|
||||
level.setConsumptionAmount("0");
|
||||
level.setDiscount(80);
|
||||
customLevelInfoService.save(level);
|
||||
} else {
|
||||
level.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
level.setDiscount(80);
|
||||
customLevelInfoService.updateById(level);
|
||||
}
|
||||
customUserInfoService.lambdaUpdate()
|
||||
.set(PlayCustomUserInfoEntity::getLevelId, levelId)
|
||||
.eq(PlayCustomUserInfoEntity::getId, ApiTestDataSeeder.DEFAULT_CUSTOMER_ID)
|
||||
.update();
|
||||
|
||||
String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/wx/pay/custom/getCustomPaymentAmount")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.param("money", "10.00"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = OBJECT_MAPPER.readTree(result.getResponse().getContentAsString());
|
||||
BigDecimal actual = root.get("data").decimalValue();
|
||||
BigDecimal expected = new BigDecimal("0.80").multiply(new BigDecimal("10.00"));
|
||||
if (actual.compareTo(expected) != 0) {
|
||||
throw new AssertionError("Expected discounted amount=" + expected + " but got " + actual);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOrderCreatesRechargeOrderAndReturnsPayParams__covers_PAY_004() throws Exception {
|
||||
String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
|
||||
|
||||
WxPayService wxPayService = org.mockito.Mockito.mock(WxPayService.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
WxPayUnifiedOrderResult result = new WxPayUnifiedOrderResult();
|
||||
result.setPrepayId("prepay-123");
|
||||
when(wxPayService.unifiedOrder(any())).thenReturn(result);
|
||||
when(wxCustomMpService.getWxPay()).thenReturn(wxPayService);
|
||||
org.mockito.Mockito.doNothing().when(wxCustomMpService).checkSubscribeThrowsExp(anyString(), anyString());
|
||||
when(wxPayService.getConfig().getAppId()).thenReturn("wx-app");
|
||||
when(wxPayService.getConfig().getMchKey()).thenReturn("mch-key");
|
||||
when(wxPayService.getConfig().getNotifyUrl()).thenReturn("https://example.com/notify");
|
||||
|
||||
mockMvc.perform(get("/wx/pay/custom/createOrder")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.param("money", "12.34"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.appId").value("wx-app"))
|
||||
.andExpect(jsonPath("$.data.package").value(org.hamcrest.Matchers.startsWith("prepay_id=")))
|
||||
.andExpect(jsonPath("$.data.signType").value("MD5"))
|
||||
.andExpect(jsonPath("$.data.paySign").isString());
|
||||
|
||||
ArgumentCaptor<WxPayUnifiedOrderRequest> requestCaptor = ArgumentCaptor.forClass(WxPayUnifiedOrderRequest.class);
|
||||
verify(wxPayService).unifiedOrder(requestCaptor.capture());
|
||||
String outTradeNo = requestCaptor.getValue().getOutTradeNo();
|
||||
|
||||
PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(outTradeNo);
|
||||
assertThat(order).as("recharge order should be created, outTradeNo=%s", outTradeNo).isNotNull();
|
||||
assertThat(order.getPurchaserBy()).isEqualTo(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
assertThat(order.getOrderType()).isEqualTo("0");
|
||||
assertThat(order.getPayState()).as("pay_state should be pending before callback").isEqualTo("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOrderRejectsWhenUserNotSubscribed__covers_PAY_005() throws Exception {
|
||||
String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
|
||||
|
||||
org.mockito.Mockito.doThrow(new ServiceException("请先关注公众号然后再来使用系统~"))
|
||||
.when(wxCustomMpService)
|
||||
.checkSubscribeThrowsExp(anyString(), anyString());
|
||||
|
||||
mockMvc.perform(get("/wx/pay/custom/createOrder")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.param("money", "10.00"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("请先关注公众号然后再来使用系统~"));
|
||||
|
||||
verify(wxCustomMpService, never()).getWxPay();
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxPayCallbackUpdatesPayStateAndBalanceAndIsIdempotent__covers_PAY_009__covers_PAY_010() throws Exception {
|
||||
PlayCustomUserInfoEntity customer = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
if (customer == null) {
|
||||
throw new AssertionError("Missing seeded customer");
|
||||
}
|
||||
BigDecimal before = customer.getAccountBalance();
|
||||
|
||||
// Create a recharge order.
|
||||
String orderNo = orderInfoService.getOrderNo();
|
||||
orderInfoService.createRechargeOrder(orderNo, new BigDecimal("10.00"), new BigDecimal("10.00"),
|
||||
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
|
||||
WxPayService wxPayService = org.mockito.Mockito.mock(WxPayService.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(wxCustomMpService.getWxPay()).thenReturn(wxPayService);
|
||||
when(wxPayService.getProfitSharingService().profitSharing(any())).thenReturn(null);
|
||||
org.mockito.Mockito.doNothing().when(wxCustomMpService).sendBalanceMessage(any());
|
||||
|
||||
String xml = "<xml>"
|
||||
+ "<out_trade_no>" + orderNo + "</out_trade_no>"
|
||||
+ "<attach>" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "</attach>"
|
||||
+ "<transaction_id>tx-1</transaction_id>"
|
||||
+ "</xml>";
|
||||
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
|
||||
if (order == null) {
|
||||
throw new AssertionError("Order not found");
|
||||
}
|
||||
if (!"1".equals(order.getPayState())) {
|
||||
throw new AssertionError("Expected pay_state=1 after callback");
|
||||
}
|
||||
|
||||
PlayCustomUserInfoEntity afterFirst = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
if (afterFirst == null) {
|
||||
throw new AssertionError("Missing customer after callback");
|
||||
}
|
||||
if (afterFirst.getAccountBalance().compareTo(before) <= 0) {
|
||||
throw new AssertionError("Expected account balance increased after callback");
|
||||
}
|
||||
|
||||
// replay same callback should not double add
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
PlayCustomUserInfoEntity afterSecond = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
if (afterSecond == null) {
|
||||
throw new AssertionError("Missing customer after replay");
|
||||
}
|
||||
if (afterSecond.getAccountBalance().compareTo(afterFirst.getAccountBalance()) != 0) {
|
||||
throw new AssertionError("Expected callback replay to be idempotent for balance");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxPayCallbackSkipsProfitSharingWhenComputedAmountIsZero__covers_PAY_012() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
SysTenantEntity tenant = tenantService.selectSysTenantByTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (tenant == null) {
|
||||
throw new AssertionError("Missing seeded tenant");
|
||||
}
|
||||
Integer originalRate = tenant.getProfitsharingRate();
|
||||
try {
|
||||
tenantService.lambdaUpdate()
|
||||
.eq(SysTenantEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.set(SysTenantEntity::getProfitsharingRate, 1)
|
||||
.update();
|
||||
|
||||
String orderNo = orderInfoService.getOrderNo();
|
||||
// 1 cent recharge with 1% profit sharing => amount=0 (int truncation)
|
||||
orderInfoService.createRechargeOrder(orderNo, new BigDecimal("0.01"), new BigDecimal("0.01"),
|
||||
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
|
||||
WxPayService wxPayService = org.mockito.Mockito.mock(WxPayService.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(wxCustomMpService.getWxPay()).thenReturn(wxPayService);
|
||||
org.mockito.Mockito.doNothing().when(wxCustomMpService).sendBalanceMessage(any());
|
||||
|
||||
String xml = "<xml>"
|
||||
+ "<out_trade_no>" + orderNo + "</out_trade_no>"
|
||||
+ "<attach>" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "</attach>"
|
||||
+ "<transaction_id>tx-ps-0</transaction_id>"
|
||||
+ "</xml>";
|
||||
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(wxPayService.getProfitSharingService(), never()).profitSharing(any());
|
||||
|
||||
PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
|
||||
if (order == null) {
|
||||
throw new AssertionError("Order not found");
|
||||
}
|
||||
if (order.getProfitSharingAmount() != null) {
|
||||
throw new AssertionError("Expected profit_sharing_amount to remain null when amount=0");
|
||||
}
|
||||
} finally {
|
||||
tenantService.lambdaUpdate()
|
||||
.eq(SysTenantEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.set(SysTenantEntity::getProfitsharingRate, originalRate == null ? 0 : originalRate)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxPayCallbackWritesProfitSharingAmountWhenRatePositive__covers_PAY_013() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
SysTenantEntity tenant = tenantService.selectSysTenantByTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (tenant == null) {
|
||||
throw new AssertionError("Missing seeded tenant");
|
||||
}
|
||||
Integer originalRate = tenant.getProfitsharingRate();
|
||||
try {
|
||||
tenantService.lambdaUpdate()
|
||||
.eq(SysTenantEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.set(SysTenantEntity::getProfitsharingRate, 1)
|
||||
.update();
|
||||
|
||||
String orderNo = orderInfoService.getOrderNo();
|
||||
orderInfoService.createRechargeOrder(orderNo, new BigDecimal("1.00"), new BigDecimal("1.00"),
|
||||
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
|
||||
WxPayService wxPayService = org.mockito.Mockito.mock(WxPayService.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(wxCustomMpService.getWxPay()).thenReturn(wxPayService);
|
||||
when(wxPayService.getProfitSharingService().profitSharing(any())).thenReturn(new ProfitSharingResult());
|
||||
org.mockito.Mockito.doNothing().when(wxCustomMpService).sendBalanceMessage(any());
|
||||
|
||||
String xml = "<xml>"
|
||||
+ "<out_trade_no>" + orderNo + "</out_trade_no>"
|
||||
+ "<attach>" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "</attach>"
|
||||
+ "<transaction_id>tx-ps-1</transaction_id>"
|
||||
+ "</xml>";
|
||||
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(wxPayService.getProfitSharingService()).profitSharing(any());
|
||||
|
||||
PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
|
||||
if (order == null) {
|
||||
throw new AssertionError("Order not found");
|
||||
}
|
||||
if (order.getProfitSharingAmount() == null) {
|
||||
throw new AssertionError("Expected profit_sharing_amount to be set");
|
||||
}
|
||||
if (order.getProfitSharingAmount().compareTo(new BigDecimal("0.01")) != 0) {
|
||||
throw new AssertionError("Expected profit_sharing_amount=0.01, got: " + order.getProfitSharingAmount());
|
||||
}
|
||||
} finally {
|
||||
tenantService.lambdaUpdate()
|
||||
.eq(SysTenantEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.set(SysTenantEntity::getProfitsharingRate, originalRate == null ? 0 : originalRate)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxPayCallbackWithInvalidXmlStillReturnsSuccessAndNoStateChange__covers_PAY_006() throws Exception {
|
||||
String xml = "<xml><bad></xml>";
|
||||
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxPayCallbackUnknownOrderDoesNotChangeState__covers_PAY_007() throws Exception {
|
||||
String orderNo = "unknown-order-no-apitest";
|
||||
String xml = "<xml>"
|
||||
+ "<out_trade_no>" + orderNo + "</out_trade_no>"
|
||||
+ "<attach>" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "</attach>"
|
||||
+ "<transaction_id>tx-unknown</transaction_id>"
|
||||
+ "</xml>";
|
||||
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
|
||||
if (order != null) {
|
||||
throw new AssertionError("Did not expect an order to be created for unknown out_trade_no");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxPayCallbackDoesNotReprocessAlreadyPaidRechargeOrder__covers_PAY_008() throws Exception {
|
||||
PlayCustomUserInfoEntity customer = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
if (customer == null) {
|
||||
throw new AssertionError("Missing seeded customer");
|
||||
}
|
||||
BigDecimal before = customer.getAccountBalance();
|
||||
|
||||
String orderNo = orderInfoService.getOrderNo();
|
||||
orderInfoService.createRechargeOrder(orderNo, new BigDecimal("10.00"), new BigDecimal("10.00"),
|
||||
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
|
||||
if (order == null) {
|
||||
throw new AssertionError("Expected order to exist");
|
||||
}
|
||||
order.setPayState("1");
|
||||
orderInfoService.updateById(order);
|
||||
|
||||
String xml = "<xml>"
|
||||
+ "<out_trade_no>" + orderNo + "</out_trade_no>"
|
||||
+ "<attach>" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "</attach>"
|
||||
+ "<transaction_id>tx-repeat</transaction_id>"
|
||||
+ "</xml>";
|
||||
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
PlayCustomUserInfoEntity after = customUserInfoService.selectById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
if (after == null) {
|
||||
throw new AssertionError("Missing customer after callback");
|
||||
}
|
||||
if (after.getAccountBalance().compareTo(before) != 0) {
|
||||
throw new AssertionError("Expected no balance change for already paid order");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxPayCallbackDoesNotProcessNonRechargeOrderType__covers_PAY_008() throws Exception {
|
||||
String orderNo = orderInfoService.getOrderNo();
|
||||
orderInfoService.createRechargeOrder(orderNo, new BigDecimal("10.00"), new BigDecimal("10.00"),
|
||||
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
|
||||
PlayOrderInfoEntity order = orderInfoService.queryByOrderNo(orderNo);
|
||||
if (order == null) {
|
||||
throw new AssertionError("Expected order to exist");
|
||||
}
|
||||
order.setOrderType("1");
|
||||
order.setPayState("0");
|
||||
orderInfoService.updateById(order);
|
||||
|
||||
String xml = "<xml>"
|
||||
+ "<out_trade_no>" + orderNo + "</out_trade_no>"
|
||||
+ "<attach>" + ApiTestDataSeeder.DEFAULT_TENANT_ID + "</attach>"
|
||||
+ "<transaction_id>tx-nonrecharge</transaction_id>"
|
||||
+ "</xml>";
|
||||
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
PlayOrderInfoEntity after = orderInfoService.queryByOrderNo(orderNo);
|
||||
if (after == null) {
|
||||
throw new AssertionError("Order missing after callback");
|
||||
}
|
||||
if (!"0".equals(after.getPayState())) {
|
||||
throw new AssertionError("Expected pay_state unchanged for non-recharge order");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxPayCallbackMissingAttachDoesNotUpdateOrder__covers_PAY_011() throws Exception {
|
||||
String orderNo = orderInfoService.getOrderNo();
|
||||
orderInfoService.createRechargeOrder(orderNo, new BigDecimal("10.00"), new BigDecimal("10.00"),
|
||||
ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
|
||||
String xml = "<xml>"
|
||||
+ "<out_trade_no>" + orderNo + "</out_trade_no>"
|
||||
+ "<transaction_id>tx-missing-attach</transaction_id>"
|
||||
+ "</xml>";
|
||||
|
||||
mockMvc.perform(post("/wx/pay/jsCallback")
|
||||
.contentType(MediaType.TEXT_XML)
|
||||
.content(xml))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
PlayOrderInfoEntity after = orderInfoService.queryByOrderNo(orderNo);
|
||||
if (after == null) {
|
||||
throw new AssertionError("Order missing after callback");
|
||||
}
|
||||
if (!"0".equals(after.getPayState())) {
|
||||
throw new AssertionError("Expected pay_state unchanged when attach is missing");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOrderRejectsMoneyBelowOne__covers_PAY_003() throws Exception {
|
||||
String token = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, token);
|
||||
|
||||
mockMvc.perform(get("/wx/pay/custom/createOrder")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + token)
|
||||
.param("money", "0.99"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500));
|
||||
|
||||
verify(wxCustomMpService, never()).getWxPay();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayShopArticleInfoEntity;
|
||||
import com.starry.admin.modules.shop.module.entity.PlayShopCarouselInfoEntity;
|
||||
import com.starry.admin.modules.shop.service.IPlayShopArticleInfoService;
|
||||
import com.starry.admin.modules.shop.service.IPlayShopCarouselInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
class WxShopControllerApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private IPlayShopCarouselInfoService carouselInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayShopArticleInfoService articleInfoService;
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
private final java.util.List<String> carouselIdsToCleanup = new java.util.ArrayList<>();
|
||||
private final java.util.List<String> articleIdsToCleanup = new java.util.ArrayList<>();
|
||||
private String clerkToken;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!carouselIdsToCleanup.isEmpty()) {
|
||||
carouselInfoService.removeByIds(carouselIdsToCleanup);
|
||||
carouselIdsToCleanup.clear();
|
||||
}
|
||||
if (!articleIdsToCleanup.isEmpty()) {
|
||||
articleInfoService.removeByIds(articleIdsToCleanup);
|
||||
articleIdsToCleanup.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getShopHomeCarouseInfoReturnsEnabledIndexZeroOnly__covers_SHOP_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String urlA = "https://example.com/carousel-a.png";
|
||||
String urlB = "https://example.com/carousel-b.png";
|
||||
insertCarousel("1", "0", urlA);
|
||||
insertCarousel("1", "0", urlB);
|
||||
insertCarousel("0", "0", "https://example.com/carousel-disabled.png");
|
||||
insertCarousel("1", "1", "https://example.com/carousel-nonhome.png");
|
||||
|
||||
mockMvc.perform(get("/wx/shop/custom/getShopHomeCarouseInfo")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andExpect(jsonPath("$.data[*].carouselUrl").value(org.hamcrest.Matchers.hasItems(urlA, urlB)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readShopArticleInfoIncrementsVisitsNumber__covers_SHOP_002() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
String articleId = insertShopArticle(0);
|
||||
|
||||
mockMvc.perform(get("/wx/shop/clerk/readShopArticleInfo")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"id\":\"" + articleId + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayShopArticleInfoEntity stored = articleInfoService.selectById(articleId);
|
||||
if (stored == null) {
|
||||
throw new AssertionError("Expected shop article to exist");
|
||||
}
|
||||
if (stored.getVisitsNumber() == null || stored.getVisitsNumber() != 1) {
|
||||
throw new AssertionError("Expected visitsNumber=1, got " + stored.getVisitsNumber());
|
||||
}
|
||||
}
|
||||
|
||||
private String insertCarousel(String enableState, String carouselIndex, String url) {
|
||||
PlayShopCarouselInfoEntity entity = new PlayShopCarouselInfoEntity();
|
||||
String id = "apitest-carousel-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setCarouselIndex(carouselIndex);
|
||||
entity.setEnableState(enableState);
|
||||
entity.setCarouselUrl(url);
|
||||
entity.setDeleted(false);
|
||||
carouselInfoService.save(entity);
|
||||
carouselIdsToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private String insertShopArticle(int visitsNumber) {
|
||||
PlayShopArticleInfoEntity entity = new PlayShopArticleInfoEntity();
|
||||
String id = "apitest-article-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setArticleType("apitest");
|
||||
entity.setArticleTitle("API Test Shop Article");
|
||||
entity.setArticleContent("content");
|
||||
entity.setVisitsNumber(visitsNumber);
|
||||
entity.setDeleted(false);
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedTime(now);
|
||||
articleInfoService.save(entity);
|
||||
articleIdsToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* End-to-end Web/API tests that prove adjustments affect clerk withdraw balance and eligibility.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until adjustments are implemented.</p>
|
||||
*/
|
||||
class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@MockBean
|
||||
private IClerkPayeeProfileService clerkPayeeProfileService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String clerkToken;
|
||||
private ClerkPayeeProfileEntity payeeProfile;
|
||||
private final List<String> createdAdjustmentIds = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ensureTenantContext();
|
||||
|
||||
payeeProfile = new ClerkPayeeProfileEntity();
|
||||
payeeProfile.setId("payee-" + IdUtils.getUuid());
|
||||
payeeProfile.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
payeeProfile.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
payeeProfile.setChannel("ALIPAY_QR");
|
||||
payeeProfile.setQrCodeUrl("https://example.com/test-payee.png");
|
||||
payeeProfile.setDisplayName("API测试收款码");
|
||||
payeeProfile.setLastConfirmedAt(LocalDateTime.now());
|
||||
|
||||
Mockito.when(clerkPayeeProfileService.getByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID, ApiTestDataSeeder.DEFAULT_CLERK_ID))
|
||||
.thenAnswer(invocation -> payeeProfile);
|
||||
Mockito.when(clerkPayeeProfileService.updateById(Mockito.any(ClerkPayeeProfileEntity.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
payeeProfile = invocation.getArgument(0);
|
||||
return true;
|
||||
});
|
||||
|
||||
clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ensureTenantContext();
|
||||
Mockito.reset(clerkPayeeProfileService);
|
||||
cleanupAdjustments();
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedPositiveAdjustmentIncreasesWithdrawableBalance() throws Exception {
|
||||
ensureTenantContext();
|
||||
BigDecimal before = fetchAvailableBalance();
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"bonus\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
BigDecimal after = fetchAvailableBalance();
|
||||
BigDecimal delta = after.subtract(before).setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(delta).isEqualByComparingTo("50.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedNegativeAdjustmentCanBlockWithdrawal() throws Exception {
|
||||
ensureTenantContext();
|
||||
String key = UUID.randomUUID().toString();
|
||||
BigDecimal before = fetchAvailableBalance();
|
||||
BigDecimal amount = before.add(new BigDecimal("20.00")).negate().setScale(2, RoundingMode.HALF_UP);
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"" + amount + "\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"penalty\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
// Attempting to withdraw any positive amount should fail when net balance is negative.
|
||||
mockMvc.perform(post("/wx/withdraw/requests")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":10}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void earningsListIncludesAdjustmentLineAfterApplied() throws Exception {
|
||||
ensureTenantContext();
|
||||
String key = UUID.randomUUID().toString();
|
||||
String effectiveTime = "2026-01-01T00:00:00";
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"12.34\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"show in list\"," +
|
||||
"\"effectiveTime\":\"" + effectiveTime + "\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
MvcResult earnings = mockMvc.perform(get("/wx/withdraw/earnings")
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "10")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(earnings.getResponse().getContentAsString());
|
||||
JsonNode rows = root.path("data");
|
||||
boolean found = false;
|
||||
if (rows.isArray()) {
|
||||
for (JsonNode row : rows) {
|
||||
if ("ADJUSTMENT".equals(row.path("earningType").asText())
|
||||
&& "12.34".equals(row.path("amount").asText())
|
||||
&& "MANUAL".equals(row.path("adjustmentReasonType").asText())
|
||||
&& "show in list".equals(row.path("adjustmentReasonDescription").asText())
|
||||
&& "2026-01-01 00:00:00".equals(row.path("adjustmentEffectiveTime").asText())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(found).isTrue();
|
||||
}
|
||||
|
||||
private BigDecimal fetchAvailableBalance() throws Exception {
|
||||
MvcResult balance = mockMvc.perform(get("/wx/withdraw/balance")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(balance.getResponse().getContentAsString());
|
||||
return root.path("data").path("available").decimalValue().setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private void ensureTenantContext() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
private void cleanupAdjustments() {
|
||||
if (createdAdjustmentIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, createdAdjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(createdAdjustmentIds);
|
||||
createdAdjustmentIds.clear();
|
||||
|
||||
withdrawalService.lambdaUpdate()
|
||||
.eq(WithdrawalRequestEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(WithdrawalRequestEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.remove();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -117,7 +121,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void balanceEndpointAggregatesAvailableAndPendingEarnings() throws Exception {
|
||||
void balanceEndpointAggregatesAvailableAndPendingEarnings__covers_WD_001() throws Exception {
|
||||
ensureTenantContext();
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
String availableId = insertEarningsLine(
|
||||
@@ -150,7 +154,77 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithdrawRejectsNonPositiveAmount() throws Exception {
|
||||
void balanceIndicatesWithdrawLockedWhenPendingRequestExists__covers_WD_001() throws Exception {
|
||||
ensureTenantContext();
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
String requestId = IdUtils.getUuid();
|
||||
req.setId(requestId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("10.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(new BigDecimal("10.00"));
|
||||
req.setStatus("pending");
|
||||
req.setCreatedTime(new Date());
|
||||
withdrawalService.save(req);
|
||||
withdrawalsToCleanup.add(requestId);
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/wx/withdraw/balance")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
JsonNode data = root.get("data");
|
||||
assertThat(data.path("withdrawLocked").asBoolean()).isTrue();
|
||||
assertThat(data.path("withdrawLockReason").asText()).isNotBlank();
|
||||
JsonNode active = data.path("activeRequest");
|
||||
assertThat(active.isMissingNode() || active.isNull()).isFalse();
|
||||
assertThat(active.path("status").asText()).isEqualTo("pending");
|
||||
assertThat(active.path("amount").decimalValue()).isEqualByComparingTo("10.00");
|
||||
assertThat(active.path("createdTime").asText()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void balanceDoesNotLockWhenActiveWithdrawalBelongsToDifferentTenant__covers_WD_001() throws Exception {
|
||||
ensureTenantContext();
|
||||
String otherTenantId = ApiTestDataSeeder.DEFAULT_TENANT_ID + "-other";
|
||||
String requestId = IdUtils.getUuid();
|
||||
|
||||
try {
|
||||
SecurityUtils.setTenantId(otherTenantId);
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
req.setId(requestId);
|
||||
req.setTenantId(otherTenantId);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("10.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(new BigDecimal("10.00"));
|
||||
req.setStatus("pending");
|
||||
req.setCreatedTime(new Date());
|
||||
withdrawalService.save(req);
|
||||
|
||||
ensureTenantContext();
|
||||
mockMvc.perform(get("/wx/withdraw/balance")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.withdrawLocked").value(false))
|
||||
.andExpect(jsonPath("$.data.withdrawLockReason").value(""));
|
||||
} finally {
|
||||
SecurityUtils.setTenantId(otherTenantId);
|
||||
withdrawalService.removeById(requestId);
|
||||
ensureTenantContext();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithdrawRejectsNonPositiveAmount__covers_WD_003() throws Exception {
|
||||
ensureTenantContext();
|
||||
mockMvc.perform(post("/wx/withdraw/requests")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
@@ -164,7 +238,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithdrawLocksEligibleEarningsLines() throws Exception {
|
||||
void createWithdrawLocksEligibleEarningsLines__covers_WD_004() throws Exception {
|
||||
ensureTenantContext();
|
||||
BigDecimal amount = new BigDecimal("80.00");
|
||||
String firstLine = insertEarningsLine(
|
||||
@@ -213,7 +287,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithdrawHandlesMixedPositiveAndNegativeLines() throws Exception {
|
||||
void createWithdrawHandlesMixedPositiveAndNegativeLines__covers_WD_004() throws Exception {
|
||||
ensureTenantContext();
|
||||
LocalDateTime base = LocalDateTime.now().minusHours(4);
|
||||
BigDecimal[] amounts = {
|
||||
@@ -244,14 +318,17 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
refreshPayeeConfirmation();
|
||||
String firstWithdrawal = createWithdraw(new BigDecimal("35"));
|
||||
assertLinesLocked(firstWithdrawal, lineIds[0], lineIds[1], lineIds[2]);
|
||||
markWithdrawalCompleted(firstWithdrawal);
|
||||
|
||||
refreshPayeeConfirmation();
|
||||
String secondWithdrawal = createWithdraw(new BigDecimal("90"));
|
||||
assertLinesLocked(secondWithdrawal, lineIds[3], lineIds[4], lineIds[5]);
|
||||
markWithdrawalCompleted(secondWithdrawal);
|
||||
|
||||
refreshPayeeConfirmation();
|
||||
String thirdWithdrawal = createWithdraw(new BigDecimal("135"));
|
||||
assertLinesLocked(thirdWithdrawal, lineIds[6], lineIds[7], lineIds[8], lineIds[9]);
|
||||
markWithdrawalCompleted(thirdWithdrawal);
|
||||
|
||||
ensureTenantContext();
|
||||
BigDecimal remaining = earningsService.getAvailableAmount(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now());
|
||||
@@ -259,7 +336,7 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void earningsEndpointFiltersByStatus() throws Exception {
|
||||
void earningsEndpointFiltersByStatus__covers_WD_002() throws Exception {
|
||||
ensureTenantContext();
|
||||
String availableId = insertEarningsLine(
|
||||
"earning-available",
|
||||
@@ -295,6 +372,122 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
.andExpect(jsonPath("$.data[1]").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentWithdrawRequestsCompeteForSameEarningsLines__covers_WD_004() throws Exception {
|
||||
ensureTenantContext();
|
||||
String firstLine = insertEarningsLine(
|
||||
"concurrent-one",
|
||||
new BigDecimal("50.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
LocalDateTime.now().minusDays(1));
|
||||
String secondLine = insertEarningsLine(
|
||||
"concurrent-two",
|
||||
new BigDecimal("30.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
LocalDateTime.now().minusHours(2));
|
||||
earningsToCleanup.add(firstLine);
|
||||
earningsToCleanup.add(secondLine);
|
||||
|
||||
refreshPayeeConfirmation();
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<Integer> create = () -> {
|
||||
MvcResult result = mockMvc.perform(post("/wx/withdraw/requests")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":80}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("code").asInt();
|
||||
};
|
||||
|
||||
Future<Integer> a = pool.submit(create);
|
||||
Future<Integer> b = pool.submit(create);
|
||||
|
||||
int codeA = a.get();
|
||||
int codeB = b.get();
|
||||
assertThat(codeA == 200 || codeA == 500).isTrue();
|
||||
assertThat(codeB == 200 || codeB == 500).isTrue();
|
||||
assertThat(codeA + codeB).isEqualTo(700);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void earningsEndpointSupportsMultipleDateFormats__covers_WD_002() throws Exception {
|
||||
ensureTenantContext();
|
||||
LocalDateTime base = LocalDateTime.now().withNano(0).withHour(10).withMinute(0).withSecond(0);
|
||||
|
||||
String early = insertEarningsLineWithCreatedTime(
|
||||
"time-early",
|
||||
new BigDecimal("10.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
base.minusDays(2));
|
||||
String middle = insertEarningsLineWithCreatedTime(
|
||||
"time-middle",
|
||||
new BigDecimal("20.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
base.minusDays(1));
|
||||
String late = insertEarningsLineWithCreatedTime(
|
||||
"time-late",
|
||||
new BigDecimal("30.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
base);
|
||||
earningsToCleanup.add(early);
|
||||
earningsToCleanup.add(middle);
|
||||
earningsToCleanup.add(late);
|
||||
|
||||
String beginIso = base.minusDays(1).format(java.time.format.DateTimeFormatter.ISO_DATE_TIME);
|
||||
String end12Hour = base.plusDays(1).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss"));
|
||||
|
||||
mockMvc.perform(get("/wx/withdraw/earnings")
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "10")
|
||||
.param("beginTime", beginIso)
|
||||
.param("endTime", end12Hour)
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.hasItems(middle, late)))
|
||||
.andExpect(jsonPath("$.data[*].id").value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem(early))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestLogsRejectsWhenNotOwner__covers_WD_005() throws Exception {
|
||||
ensureTenantContext();
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
String requestId = "apitest-withdraw-" + IdUtils.getUuid();
|
||||
req.setId(requestId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("10.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(new BigDecimal("10.00"));
|
||||
req.setStatus("pending");
|
||||
withdrawalService.save(req);
|
||||
withdrawalsToCleanup.add(requestId);
|
||||
|
||||
String otherClerkId = "apitest-clerk-other-" + IdUtils.getUuid().substring(0, 6);
|
||||
String otherToken = wxTokenService.createWxUserToken(otherClerkId);
|
||||
ensureActiveClerk(otherClerkId, "openid-" + otherClerkId, otherToken);
|
||||
|
||||
mockMvc.perform(get("/wx/withdraw/requests/" + requestId + "/logs")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + otherToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("无权查看"));
|
||||
}
|
||||
|
||||
private String insertEarningsLine(
|
||||
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
|
||||
return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);
|
||||
@@ -326,6 +519,72 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
return id;
|
||||
}
|
||||
|
||||
private String insertEarningsLineWithCreatedTime(
|
||||
String suffix,
|
||||
BigDecimal amount,
|
||||
EarningsStatus status,
|
||||
LocalDateTime createdAt) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-" + suffix + "-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
String rawOrderId = ORDER_ID_PREFIX + IdUtils.getUuid();
|
||||
entity.setOrderId(rawOrderId.length() <= 32 ? rawOrderId : rawOrderId.substring(0, 32));
|
||||
entity.setAmount(amount);
|
||||
entity.setStatus(status.getCode());
|
||||
entity.setUnlockTime(createdAt.plusHours(1));
|
||||
Date stamp = toDate(createdAt);
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(toDate(LocalDateTime.now()));
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(toDate(LocalDateTime.now()));
|
||||
earningsService.save(entity);
|
||||
earningsService.lambdaUpdate()
|
||||
.set(EarningsLineEntity::getCreatedTime, stamp)
|
||||
.set(EarningsLineEntity::getUpdatedTime, stamp)
|
||||
.eq(EarningsLineEntity::getId, id)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private void ensureActiveClerk(String clerkId, String openId, String token) {
|
||||
ensureTenantContext();
|
||||
com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(clerkId);
|
||||
if (existing == null) {
|
||||
com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity entity =
|
||||
new com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity();
|
||||
entity.setId(clerkId);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setOpenid(openId);
|
||||
entity.setNickname("API Test Clerk");
|
||||
entity.setAvatar("https://example.com/avatar.png");
|
||||
entity.setSysUserId("");
|
||||
entity.setOnboardingState("1");
|
||||
entity.setListingState("1");
|
||||
entity.setClerkState("1");
|
||||
entity.setOnlineState("1");
|
||||
entity.setToken(token);
|
||||
clerkUserInfoService.save(entity);
|
||||
return;
|
||||
}
|
||||
com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity patch =
|
||||
new com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity();
|
||||
patch.setId(clerkId);
|
||||
patch.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
patch.setOpenid(openId);
|
||||
patch.setNickname(existing.getNickname());
|
||||
patch.setAvatar(existing.getAvatar());
|
||||
patch.setSysUserId("");
|
||||
patch.setOnboardingState("1");
|
||||
patch.setListingState("1");
|
||||
patch.setClerkState("1");
|
||||
patch.setOnlineState("1");
|
||||
patch.setDeleted(Boolean.FALSE);
|
||||
patch.setToken(token);
|
||||
clerkUserInfoService.updateById(patch);
|
||||
}
|
||||
|
||||
private void ensureTenantContext() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
@@ -363,6 +622,14 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void markWithdrawalCompleted(String withdrawalId) {
|
||||
ensureTenantContext();
|
||||
WithdrawalRequestEntity patch = new WithdrawalRequestEntity();
|
||||
patch.setId(withdrawalId);
|
||||
patch.setStatus("success");
|
||||
withdrawalService.updateById(patch);
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime value) {
|
||||
return Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
class WxWithdrawPayeeControllerApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IClerkPayeeProfileService payeeProfileService;
|
||||
|
||||
private String clerkId;
|
||||
private String clerkToken;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
clerkId = "clerk-payee-" + IdUtils.getUuid();
|
||||
String clerkOpenId = "openid-payee-" + IdUtils.getUuid();
|
||||
clerkToken = wxTokenService.createWxUserToken(clerkId);
|
||||
String phone = "139" + String.format("%08d", java.util.concurrent.ThreadLocalRandom.current().nextInt(0, 100000000));
|
||||
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
clerk.setOpenid(clerkOpenId);
|
||||
clerk.setNickname("payee-test");
|
||||
clerk.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone(phone);
|
||||
clerk.setWeiChatCode("payee-test-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(java.math.BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(java.time.LocalDateTime.now());
|
||||
clerk.setToken(clerkToken);
|
||||
clerkUserInfoService.save(clerk);
|
||||
|
||||
payeeProfileService.lambdaUpdate()
|
||||
.eq(ClerkPayeeProfileEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(ClerkPayeeProfileEntity::getClerkId, clerkId)
|
||||
.remove();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
payeeProfileService.lambdaUpdate()
|
||||
.eq(ClerkPayeeProfileEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(ClerkPayeeProfileEntity::getClerkId, clerkId)
|
||||
.remove();
|
||||
|
||||
clerkUserInfoService.lambdaUpdate()
|
||||
.eq(PlayClerkUserInfoEntity::getId, clerkId)
|
||||
.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProfileReturnsNullWhenAbsent__covers_PAYEE_001() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(get("/wx/withdraw/payee")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertRejectsMissingQrCodeUrl__covers_PAYEE_002() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(post("/wx/withdraw/payee")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"qrCodeUrl\":\"\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("请上传支付宝收款二维码"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertDefaultsChannelAndDisplayNameAndClearsConfirmation__covers_PAYEE_003() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
PlayClerkUserInfoEntity clerk = clerkUserInfoService.selectById(clerkId);
|
||||
String expectedDisplayName = clerk != null ? clerk.getNickname() : null;
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/wx/withdraw/payee")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"channel\":\"\",\"qrCodeUrl\":\"https://example.com/payee.png\",\"displayName\":\"\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
String responseBody = result.getResponse().getContentAsString();
|
||||
Integer businessCode = JsonPath.read(responseBody, "$.code");
|
||||
Assertions.assertEquals(200, businessCode, "Unexpected response: " + responseBody);
|
||||
Assertions.assertEquals("ALIPAY_QR", JsonPath.read(responseBody, "$.data.channel"));
|
||||
Assertions.assertEquals("https://example.com/payee.png", JsonPath.read(responseBody, "$.data.qrCodeUrl"));
|
||||
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
ClerkPayeeProfileEntity stored = payeeProfileService.getByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID, clerkId);
|
||||
if (stored == null) {
|
||||
throw new AssertionError("Expected payee profile to be stored");
|
||||
}
|
||||
if (!"ALIPAY_QR".equals(stored.getChannel())) {
|
||||
throw new AssertionError("Expected default channel ALIPAY_QR");
|
||||
}
|
||||
if (expectedDisplayName != null && !expectedDisplayName.equals(stored.getDisplayName())) {
|
||||
throw new AssertionError("Expected displayName default to clerk nickname");
|
||||
}
|
||||
if (stored.getLastConfirmedAt() != null) {
|
||||
throw new AssertionError("Expected lastConfirmedAt to be cleared after upsert");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmRejectsWhenNoQrCodeUploaded__covers_PAYEE_004() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
mockMvc.perform(post("/wx/withdraw/payee/confirm")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"trace\":\"" + IdUtils.getUuid() + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.message").value("请先上传支付宝收款二维码"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.starry.admin.common.security.filter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.starry.admin.common.component.JwtToken;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.custom.service.impl.PlayCustomUserInfoServiceImpl;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
|
||||
import com.starry.admin.modules.system.service.ISysTenantService;
|
||||
import com.starry.admin.modules.system.service.SysUserService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.common.redis.RedisCache;
|
||||
import java.io.IOException;
|
||||
import java.util.Date;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.web.servlet.HandlerExceptionResolver;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JwtAuthenticationTokenFilterTest {
|
||||
|
||||
@Mock
|
||||
private WxTokenService tokenService;
|
||||
|
||||
@Mock
|
||||
private JwtToken jwtToken;
|
||||
|
||||
@Mock
|
||||
private HandlerExceptionResolver resolver;
|
||||
|
||||
@Mock
|
||||
private PlayCustomUserInfoServiceImpl customUserInfoService;
|
||||
|
||||
@Mock
|
||||
private PlayClerkUserInfoServiceImpl clerkUserInfoService;
|
||||
|
||||
@Mock
|
||||
private ISysTenantService sysTenantService;
|
||||
|
||||
@Mock
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Mock
|
||||
private SysUserService userService;
|
||||
|
||||
@InjectMocks
|
||||
private JwtAuthenticationTokenFilter filter;
|
||||
|
||||
@Test
|
||||
void wxPayCallbackBypassesTenantKeyAndAuth__covers_GW_003() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/wx/pay/jsCallback");
|
||||
request.setServletPath("/wx/pay/jsCallback");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getHeader("X-Chain")).isEqualTo("called");
|
||||
verify(resolver, never()).resolveException(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxRouteMissingTenantKeyTriggersResolverAndStopsChain__covers_GW_001__covers_GW_002() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/custom/queryById");
|
||||
request.setServletPath("/wx/custom/queryById");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getHeader("X-Chain")).isNull();
|
||||
verify(resolver).resolveException(eq(request), eq(response), eq(null), any(CustomException.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noLoginWxRouteInvalidTenantTriggersResolver__covers_GW_004() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/common/area/tree");
|
||||
request.setServletPath("/wx/common/area/tree");
|
||||
request.addHeader("tenantkey", "unknown-tenant");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
|
||||
|
||||
when(sysTenantService.selectByTenantKey("unknown-tenant")).thenReturn(null);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getHeader("X-Chain")).isNull();
|
||||
verify(resolver).resolveException(eq(request), eq(response), eq(null), any(CustomException.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noLoginWxRouteTenantLookupThrowsReturns401Json__covers_GW_004() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/common/area/tree");
|
||||
request.setServletPath("/wx/common/area/tree");
|
||||
request.addHeader("tenantkey", "tenantkey-1");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
|
||||
|
||||
when(sysTenantService.selectByTenantKey("tenantkey-1")).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getHeader("X-Chain")).isNull();
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getContentAsString()).contains("\"code\":401");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRequiredWxRouteTenantLookupThrowsReturns401Json__covers_GW_005__covers_GW_006() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/custom/queryById");
|
||||
request.setServletPath("/wx/custom/queryById");
|
||||
request.addHeader("tenantkey", "tenantkey-1");
|
||||
request.addHeader(com.starry.common.constant.Constants.CUSTOM_USER_LOGIN_TOKEN, "Bearer bad-token");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
|
||||
|
||||
when(tokenService.getWxUserIdByToken(any())).thenThrow(new RuntimeException("bad token"));
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getHeader("X-Chain")).isNull();
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getContentAsString()).contains("\"code\":401");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wxRouteWithValidTenantPassesChainAndSetsTenantContext__covers_GW_004() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/wx/common/area/tree");
|
||||
request.setServletPath("/wx/common/area/tree");
|
||||
request.addHeader("tenantkey", "tenantkey-1");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
FilterChain chain = (req, resp) -> response.addHeader("X-Chain", "called");
|
||||
|
||||
SysTenantEntity tenant = new SysTenantEntity();
|
||||
tenant.setTenantId("tenant-1");
|
||||
tenant.setTenantTime(new Date(System.currentTimeMillis() + 3600_000));
|
||||
when(sysTenantService.selectByTenantKey("tenantkey-1")).thenReturn(tenant);
|
||||
when(sysTenantService.selectSysTenantByTenantId("tenant-1")).thenReturn(tenant);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getHeader("X-Chain")).isEqualTo("called");
|
||||
verify(resolver, never()).resolveException(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTenantIdUsesClerkTokenWhenProvided__covers_GW_005() {
|
||||
when(tokenService.getWxUserIdByToken(any())).thenReturn("user-1");
|
||||
when(redisCache.getCacheObject("TENANT_INFO:user-1")).thenReturn("tenant-from-redis");
|
||||
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setTenantId("tenant-clerk");
|
||||
when(clerkUserInfoService.selectById("user-1")).thenReturn(clerk);
|
||||
|
||||
String tenantId = filter.getTenantId("Bearer clerk", "Bearer custom", "tenantkey-1");
|
||||
assertThat(tenantId).isEqualTo("tenant-clerk");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTenantIdUsesCustomTokenWhenNoClerkToken__covers_GW_006() {
|
||||
when(tokenService.getWxUserIdByToken(any())).thenReturn("user-2");
|
||||
when(redisCache.getCacheObject("TENANT_INFO:user-2")).thenReturn("tenant-from-redis");
|
||||
|
||||
PlayCustomUserInfoEntity custom = new PlayCustomUserInfoEntity();
|
||||
custom.setTenantId("tenant-custom");
|
||||
when(customUserInfoService.selectById("user-2")).thenReturn(custom);
|
||||
|
||||
String tenantId = filter.getTenantId(null, "Bearer custom", "tenantkey-1");
|
||||
assertThat(tenantId).isEqualTo("tenant-custom");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.starry.admin.db;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.starry.admin.api.AbstractApiTest;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* Database-level contract tests for the adjustment system schema.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until migrations add the new table/columns/indexes.</p>
|
||||
*/
|
||||
class EarningsAdjustmentsDatabaseSchemaApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
void earningsLineHasSourceColumnsAndOrderIdIsNullable() {
|
||||
assertThat(columnExists("play_earnings_line", "source_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line", "source_id")).isTrue();
|
||||
|
||||
Map<String, Object> orderIdMeta = jdbcTemplate.queryForMap(
|
||||
"select is_nullable as nullable " +
|
||||
"from information_schema.columns " +
|
||||
"where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
|
||||
"play_earnings_line",
|
||||
"order_id");
|
||||
String nullable = String.valueOf(orderIdMeta.get("nullable"));
|
||||
assertThat(nullable).isIn("YES", "yes", "Y", "y", "1", "TRUE", "true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentTableExistsAndHasIdempotencyFields() {
|
||||
assertThat(tableExists("play_earnings_line_adjustment")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "idempotency_key")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "request_hash")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "status")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "reason_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "reason_description")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "effective_time")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueIndexExistsForTenantIdempotencyKey() {
|
||||
// Lock the index name so future migrations are deterministic.
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) " +
|
||||
"from information_schema.statistics " +
|
||||
"where lower(table_name)=lower(?) and lower(index_name)=lower(?)",
|
||||
Integer.class,
|
||||
"play_earnings_line_adjustment",
|
||||
"uk_tenant_idempotency");
|
||||
assertThat(count).isNotNull();
|
||||
assertThat(count).isGreaterThan(0);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private boolean columnExists(String table, String column) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.columns where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
|
||||
Integer.class,
|
||||
table,
|
||||
column);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.starry.admin.db;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.starry.admin.api.AbstractApiTest;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* Database-level contract tests for the batch deductions system schema.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until migrations add the new tables/indexes.</p>
|
||||
*/
|
||||
class EarningsDeductionBatchDatabaseSchemaApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
void batchTablesExist() {
|
||||
assertThat(tableExists("play_earnings_deduction_batch")).isTrue();
|
||||
assertThat(tableExists("play_earnings_deduction_item")).isTrue();
|
||||
assertThat(tableExists("play_earnings_deduction_batch_log")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchTableHasIdempotencyAndWindowFields() {
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "tenant_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "status")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "idempotency_key")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "request_hash")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "begin_time")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "end_time")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "rule_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "rule_value")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "reason_description")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemTableHasAmountsAndAdjustmentLink() {
|
||||
assertThat(columnExists("play_earnings_deduction_item", "batch_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "tenant_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "clerk_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "base_amount")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "apply_amount")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "status")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "adjustment_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "failure_reason")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logTableHasEventFields() {
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "batch_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "tenant_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "event_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "status_from")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "status_to")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "message")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "payload")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueIndexExistsForTenantIdempotencyKey() {
|
||||
assertThat(indexExists("play_earnings_deduction_batch", "uk_tenant_idempotency")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueIndexExistsForBatchClerk() {
|
||||
assertThat(indexExists("play_earnings_deduction_item", "uk_batch_clerk")).isTrue();
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private boolean columnExists(String table, String column) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.columns where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
|
||||
Integer.class,
|
||||
table,
|
||||
column);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private boolean indexExists(String table, String indexName) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.statistics " +
|
||||
"where lower(table_name)=lower(?) and lower(index_name)=lower(?)",
|
||||
Integer.class,
|
||||
table,
|
||||
indexName);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private List<String> listIndexes(String table) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"select distinct index_name from information_schema.statistics where lower(table_name)=lower(?)",
|
||||
String.class,
|
||||
table);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,9 @@ import javax.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
class WxPkApiTest extends AbstractApiTest {
|
||||
|
||||
@@ -91,6 +93,12 @@ class WxPkApiTest extends AbstractApiTest {
|
||||
@Resource
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Resource
|
||||
private com.starry.admin.modules.weichat.controller.WxPkController wxPkController;
|
||||
|
||||
@Resource
|
||||
private GlobalExceptionHandler globalExceptionHandler;
|
||||
|
||||
@BeforeEach
|
||||
void clearRedis() {
|
||||
if (stringRedisTemplate.getConnectionFactory() == null) {
|
||||
@@ -100,7 +108,7 @@ class WxPkApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLiveShouldRejectWhenClerkIdMissing() throws Exception {
|
||||
void clerkLiveShouldRejectWhenClerkIdMissing__covers_PK_001() throws Exception {
|
||||
mockMvc.perform(get("/wx/pk/clerk/live")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
@@ -110,7 +118,7 @@ class WxPkApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLiveShouldReturnInactiveWhenNoActivePk() throws Exception {
|
||||
void clerkLiveShouldReturnInactiveWhenNoActivePk__covers_PK_002() throws Exception {
|
||||
String clerkId = newClerkId();
|
||||
mockMvc.perform(get("/wx/pk/clerk/live")
|
||||
.param(PARAM_CLERK_ID, clerkId)
|
||||
@@ -151,7 +159,7 @@ class WxPkApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkLiveShouldReturnInactiveWhenToBeStarted() throws Exception {
|
||||
void clerkLiveShouldReturnInactiveWhenToBeStarted__covers_PK_003() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String clerkId = newClerkId();
|
||||
PlayClerkPkEntity pk = buildPk(newPkId(), clerkId, newClerkId(),
|
||||
@@ -170,7 +178,20 @@ class WxPkApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void upcomingShouldReturnInactiveWhenRedisEmpty() throws Exception {
|
||||
void upcomingShouldRejectWhenTenantMissing__covers_PK_004() throws Exception {
|
||||
com.starry.common.context.CustomSecurityContextHolder.remove();
|
||||
MockMvc standalone = MockMvcBuilders.standaloneSetup(wxPkController)
|
||||
.setControllerAdvice(globalExceptionHandler)
|
||||
.build();
|
||||
|
||||
standalone.perform(get("/wx/pk/upcoming"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode()))
|
||||
.andExpect(jsonPath("$.message").value("租户信息缺失"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void upcomingShouldReturnInactiveWhenRedisEmpty__covers_PK_005() throws Exception {
|
||||
mockMvc.perform(get("/wx/pk/upcoming")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
@@ -179,7 +200,7 @@ class WxPkApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void upcomingShouldReturnUpcomingWhenRedisHasPk() throws Exception {
|
||||
void upcomingShouldReturnUpcomingWhenRedisHasPk__covers_PK_005() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String pkId = newPkId();
|
||||
PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(),
|
||||
@@ -358,7 +379,7 @@ class WxPkApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkScheduleShouldRejectWhenClerkIdMissing() throws Exception {
|
||||
void clerkScheduleShouldRejectWhenClerkIdMissing__covers_PK_006() throws Exception {
|
||||
mockMvc.perform(get("/wx/pk/clerk/schedule")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
@@ -409,7 +430,7 @@ class WxPkApiTest extends AbstractApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkScheduleShouldApplyLimitDefaultWhenMissing() throws Exception {
|
||||
void clerkScheduleShouldApplyLimitDefaultWhenMissing__covers_PK_006() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String clerkId = newClerkId();
|
||||
SecurityUtils.setTenantId(DEFAULT_TENANT);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.starry.admin.modules.weichat.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
@@ -8,17 +10,26 @@ import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.common.exception.ServiceException;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.module.enums.ListingStatus;
|
||||
import com.starry.admin.modules.clerk.module.enums.OnboardingStatus;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
|
||||
import com.starry.admin.modules.system.service.impl.SysTenantServiceImpl;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import me.chanjar.weixin.common.error.WxErrorException;
|
||||
import me.chanjar.weixin.mp.api.WxMpService;
|
||||
import me.chanjar.weixin.mp.api.WxMpTemplateMsgService;
|
||||
import me.chanjar.weixin.mp.api.WxMpUserService;
|
||||
import me.chanjar.weixin.mp.bean.result.WxMpUser;
|
||||
import me.chanjar.weixin.mp.bean.template.WxMpTemplateData;
|
||||
import me.chanjar.weixin.mp.bean.template.WxMpTemplateMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -26,8 +37,10 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WxCustomMpServiceTest {
|
||||
@@ -69,7 +82,35 @@ class WxCustomMpServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendCreateOrderMessageUsesOrderLabelResolver() throws WxErrorException {
|
||||
void proxyWxMpServiceRejectsMissingTenantId__covers_MP_001() {
|
||||
try {
|
||||
SecurityUtils.setTenantId("");
|
||||
assertThatThrownBy(wxCustomMpService::proxyWxMpService)
|
||||
.isInstanceOf(CustomException.class)
|
||||
.hasMessage("系统错误,租户ID不能为空");
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWxPayRejectsWhenTenantMissingMchId__covers_MP_002() {
|
||||
try {
|
||||
SecurityUtils.setTenantId(TENANT_ID);
|
||||
SysTenantEntity tenant = buildTenant();
|
||||
tenant.setMchId("");
|
||||
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
|
||||
|
||||
assertThatThrownBy(wxCustomMpService::getWxPay)
|
||||
.isInstanceOf(CustomException.class)
|
||||
.hasMessage("商户号不能为空,请联系平台方进行配置");
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendCreateOrderMessageUsesOrderLabelResolver__covers_MP_003() throws WxErrorException {
|
||||
SysTenantEntity tenant = buildTenant();
|
||||
|
||||
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
|
||||
@@ -114,7 +155,7 @@ class WxCustomMpServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendCreateOrderMessageBatchFiltersInactiveClerksAndForwardsLabel() throws WxErrorException {
|
||||
void sendCreateOrderMessageBatchFiltersInactiveClerksAndForwardsLabel__covers_MP_004() throws WxErrorException {
|
||||
SysTenantEntity tenant = buildTenant();
|
||||
|
||||
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
|
||||
@@ -170,4 +211,104 @@ class WxCustomMpServiceTest {
|
||||
.extracting(WxMpTemplateData::getValue)
|
||||
.isEqualTo("指定单");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendBalanceMessageBuildsRechargeTemplateData__covers_MP_005() throws WxErrorException {
|
||||
try {
|
||||
SecurityUtils.setTenantId(TENANT_ID);
|
||||
|
||||
SysTenantEntity tenant = buildTenant();
|
||||
tenant.setChongzhichenggongTemplateId("template-recharge-success");
|
||||
tenant.setTenantName("租户名称");
|
||||
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
|
||||
|
||||
when(wxMpService.switchoverTo(tenant.getAppId())).thenReturn(wxMpService);
|
||||
when(wxMpService.getTemplateMsgService()).thenReturn(templateMsgService);
|
||||
|
||||
PlayCustomUserInfoEntity customUser = new PlayCustomUserInfoEntity();
|
||||
customUser.setId("customer-1");
|
||||
customUser.setOpenid("openid-customer");
|
||||
when(customUserInfoService.selectById(customUser.getId())).thenReturn(customUser);
|
||||
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
order.setTenantId(TENANT_ID);
|
||||
order.setPurchaserBy(customUser.getId());
|
||||
order.setOrderMoney(new BigDecimal("10.00"));
|
||||
|
||||
wxCustomMpService.sendBalanceMessage(order);
|
||||
|
||||
ArgumentCaptor<WxMpTemplateMessage> captor = ArgumentCaptor.forClass(WxMpTemplateMessage.class);
|
||||
verify(templateMsgService).sendTemplateMsg(captor.capture());
|
||||
|
||||
WxMpTemplateMessage message = captor.getValue();
|
||||
assertThat(message.getTemplateId()).isEqualTo("template-recharge-success");
|
||||
assertThat(message.getToUser()).isEqualTo("openid-customer");
|
||||
assertThat(message.getUrl()).isEqualTo("https://tenant-key.julyharbor.com/user/");
|
||||
|
||||
assertThat(message.getData())
|
||||
.filteredOn(item -> "amount2".equals(item.getName()))
|
||||
.singleElement()
|
||||
.extracting(WxMpTemplateData::getValue)
|
||||
.isEqualTo("10.00");
|
||||
assertThat(message.getData())
|
||||
.filteredOn(item -> "amount17".equals(item.getName()))
|
||||
.singleElement()
|
||||
.extracting(WxMpTemplateData::getValue)
|
||||
.isEqualTo("0");
|
||||
assertThat(message.getData())
|
||||
.filteredOn(item -> "thing10".equals(item.getName()))
|
||||
.singleElement()
|
||||
.extracting(WxMpTemplateData::getValue)
|
||||
.isEqualTo("租户名称");
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendOrderFinishMessageOnlyTriggersForPlaceType12__covers_MP_006() throws WxErrorException {
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
order.setPlaceType("3");
|
||||
|
||||
wxCustomMpService.sendOrderFinishMessage(order);
|
||||
|
||||
verify(templateMsgService, Mockito.never()).sendTemplateMsg(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncWrappersSwallowExceptionsFromUnderlyingSend__covers_MP_007() {
|
||||
doAnswer(invocation -> {
|
||||
Runnable runnable = invocation.getArgument(0);
|
||||
runnable.run();
|
||||
return null;
|
||||
}).when(executor).execute(any(Runnable.class));
|
||||
|
||||
WxCustomMpService spy = Mockito.spy(wxCustomMpService);
|
||||
Mockito.doThrow(new RuntimeException("boom")).when(spy).sendOrderFinishMessage(any());
|
||||
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
order.setId("order-async-1");
|
||||
|
||||
assertThatCode(() -> spy.sendOrderFinishMessageAsync(order)).doesNotThrowAnyException();
|
||||
verify(executor).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkSubscribeThrowsWhenUserNotSubscribed__covers_MP_008() throws WxErrorException {
|
||||
ReflectionTestUtils.setField(wxCustomMpService, "subscribeCheckEnabled", true);
|
||||
SysTenantEntity tenant = buildTenant();
|
||||
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
|
||||
when(wxMpService.switchoverTo(tenant.getAppId())).thenReturn(wxMpService);
|
||||
|
||||
WxMpUserService userService = Mockito.mock(WxMpUserService.class);
|
||||
when(wxMpService.getUserService()).thenReturn(userService);
|
||||
|
||||
WxMpUser user = new WxMpUser();
|
||||
user.setSubscribe(false);
|
||||
when(userService.userInfo("openid-1")).thenReturn(user);
|
||||
|
||||
assertThatThrownBy(() -> wxCustomMpService.checkSubscribeThrowsExp("openid-1", TENANT_ID))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("请先关注公众号然后再来使用系统~");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.starry.admin.modules.weichat.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import com.starry.admin.common.exception.ServiceException;
|
||||
import com.starry.admin.common.oss.service.IOssFileService;
|
||||
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.IPlayClerkLevelInfoService;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomLevelInfoService;
|
||||
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import me.chanjar.weixin.common.bean.WxOAuth2UserInfo;
|
||||
import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
|
||||
import me.chanjar.weixin.mp.api.WxMpService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WxOauthServiceTest {
|
||||
|
||||
@Mock
|
||||
private WxCustomMpService wxCustomMpService;
|
||||
|
||||
@Mock
|
||||
private IPlayCustomUserInfoService customUserInfoService;
|
||||
|
||||
@Mock
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Mock
|
||||
private IPlayClerkLevelInfoService playClerkLevelInfoService;
|
||||
|
||||
@Mock
|
||||
private IPlayCustomLevelInfoService playCustomLevelInfoService;
|
||||
|
||||
@Mock
|
||||
private IOssFileService ossFileService;
|
||||
|
||||
@InjectMocks
|
||||
private WxOauthService wxOauthService;
|
||||
|
||||
@Test
|
||||
void getWxOAuth2AccessTokenRejectsBlankCode__covers_OAUTH_009() {
|
||||
try {
|
||||
wxOauthService.getWxOAuth2AccessToken(" ");
|
||||
} catch (ServiceException ex) {
|
||||
assertThat(ex.getMessage()).isEqualTo("不能为空");
|
||||
return;
|
||||
}
|
||||
throw new AssertionError("Expected ServiceException");
|
||||
}
|
||||
|
||||
@Test
|
||||
void customUserLoginCreatesNewUserAndUpdatesLastLogin__covers_OAUTH_009() throws Exception {
|
||||
WxMpService wxMpService = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
|
||||
when(wxCustomMpService.proxyWxMpService()).thenReturn(wxMpService);
|
||||
|
||||
WxOAuth2AccessToken token = new WxOAuth2AccessToken();
|
||||
token.setOpenId("openid-1");
|
||||
when(wxMpService.getOAuth2Service().getAccessToken("code-1")).thenReturn(token);
|
||||
|
||||
WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
|
||||
userInfo.setOpenid("openid-1");
|
||||
userInfo.setNickname("Nick");
|
||||
userInfo.setHeadImgUrl("https://example.com/avatar.png");
|
||||
when(wxMpService.getOAuth2Service().getUserInfo(eq(token), eq(null))).thenReturn(userInfo);
|
||||
|
||||
when(customUserInfoService.selectByOpenid("openid-1")).thenReturn(null);
|
||||
com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity defaultLevel =
|
||||
new com.starry.admin.modules.custom.module.entity.PlayCustomLevelInfoEntity();
|
||||
defaultLevel.setId("lvl-1");
|
||||
when(playCustomLevelInfoService.getDefaultLevel()).thenReturn(defaultLevel);
|
||||
|
||||
String userId = wxOauthService.customUserLogin("code-1");
|
||||
assertThat(userId).isNotBlank();
|
||||
verify(customUserInfoService).saveOrUpdate(any(PlayCustomUserInfoEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkUserLoginCreatesNewClerkWithAvatarAndDefaultStates__covers_OAUTH_010() throws Exception {
|
||||
SecurityUtils.setTenantId("tenant-1");
|
||||
|
||||
WxMpService wxMpService = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
|
||||
when(wxCustomMpService.proxyWxMpService()).thenReturn(wxMpService);
|
||||
|
||||
WxOAuth2AccessToken token = new WxOAuth2AccessToken();
|
||||
token.setOpenId("openid-clerk-1");
|
||||
when(wxMpService.getOAuth2Service().getAccessToken("code-2")).thenReturn(token);
|
||||
|
||||
WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
|
||||
userInfo.setOpenid("openid-clerk-1");
|
||||
userInfo.setNickname("Lily");
|
||||
userInfo.setHeadImgUrl("https://example.com/avatar.png");
|
||||
when(wxMpService.getOAuth2Service().getUserInfo(eq(token), eq(null))).thenReturn(userInfo);
|
||||
|
||||
when(clerkUserInfoService.selectByOpenid("openid-clerk-1")).thenReturn(null);
|
||||
com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity defaultClerkLevel =
|
||||
new com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity();
|
||||
defaultClerkLevel.setId("lvl-clerk");
|
||||
when(playClerkLevelInfoService.getDefaultLevel()).thenReturn(defaultClerkLevel);
|
||||
when(ossFileService.upload(any(), eq("tenant-1"), eq("image"))).thenReturn("https://oss.example/avatar.jpg");
|
||||
|
||||
HttpRequest httpRequest = mock(HttpRequest.class, Mockito.RETURNS_DEEP_STUBS);
|
||||
try (MockedStatic<HttpRequest> mocked = Mockito.mockStatic(HttpRequest.class)) {
|
||||
mocked.when(() -> HttpRequest.get("https://example.com/avatar.png")).thenReturn(httpRequest);
|
||||
when(httpRequest.execute().bodyStream()).thenReturn(new ByteArrayInputStream("img".getBytes()));
|
||||
|
||||
String clerkId = wxOauthService.clerkUserLogin("code-2");
|
||||
assertThat(clerkId).isNotBlank();
|
||||
|
||||
verify(clerkUserInfoService).create(Mockito.argThat(entity -> {
|
||||
if (!"openid-clerk-1".equals(entity.getOpenid())) {
|
||||
return false;
|
||||
}
|
||||
if (!"https://oss.example/avatar.jpg".equals(entity.getAvatar())) {
|
||||
return false;
|
||||
}
|
||||
return "lvl-clerk".equals(entity.getLevelId())
|
||||
&& ClerkRoleStatus.NON_CLERK.getCode().equals(entity.getClerkState())
|
||||
&& OnboardingStatus.ACTIVE.getCode().equals(entity.getOnboardingState())
|
||||
&& ListingStatus.LISTED.getCode().equals(entity.getListingState());
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkUserLoginRestoresDeletedClerk__covers_OAUTH_010() throws Exception {
|
||||
WxMpService wxMpService = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
|
||||
when(wxCustomMpService.proxyWxMpService()).thenReturn(wxMpService);
|
||||
|
||||
WxOAuth2AccessToken token = new WxOAuth2AccessToken();
|
||||
token.setOpenId("openid-restore");
|
||||
when(wxMpService.getOAuth2Service().getAccessToken("code-restore")).thenReturn(token);
|
||||
|
||||
WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
|
||||
userInfo.setOpenid("openid-restore");
|
||||
userInfo.setNickname("Restored");
|
||||
userInfo.setHeadImgUrl("https://example.com/avatar.png");
|
||||
when(wxMpService.getOAuth2Service().getUserInfo(eq(token), eq(null))).thenReturn(userInfo);
|
||||
|
||||
PlayClerkUserInfoEntity existing = new PlayClerkUserInfoEntity();
|
||||
existing.setId("clerk-restore");
|
||||
existing.setOpenid("openid-restore");
|
||||
existing.setDeleted(true);
|
||||
existing.setToken("old");
|
||||
existing.setOnlineState("1");
|
||||
existing.setOnboardingState("");
|
||||
existing.setListingState("");
|
||||
existing.setClerkState("");
|
||||
|
||||
when(clerkUserInfoService.selectByOpenid("openid-restore")).thenReturn(existing);
|
||||
|
||||
String clerkId = wxOauthService.clerkUserLogin("code-restore");
|
||||
assertThat(clerkId).isEqualTo("clerk-restore");
|
||||
assertThat(existing.getDeleted()).isFalse();
|
||||
assertThat(existing.getToken()).isEqualTo("empty");
|
||||
assertThat(existing.getOnlineState()).isEqualTo("0");
|
||||
assertThat(existing.getOnboardingState()).isEqualTo(OnboardingStatus.ACTIVE.getCode());
|
||||
assertThat(existing.getListingState()).isEqualTo(ListingStatus.LISTED.getCode());
|
||||
assertThat(existing.getClerkState()).isEqualTo(ClerkRoleStatus.NON_CLERK.getCode());
|
||||
|
||||
verify(clerkUserInfoService).update(eq(null), any());
|
||||
verify(clerkUserInfoService).ensureClerkSessionIsValid(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clerkUserLoginDoesNotDownloadAvatarWhenAlreadyPresent__covers_OAUTH_010() throws Exception {
|
||||
WxMpService wxMpService = mock(WxMpService.class, Mockito.RETURNS_DEEP_STUBS);
|
||||
when(wxCustomMpService.proxyWxMpService()).thenReturn(wxMpService);
|
||||
|
||||
WxOAuth2AccessToken token = new WxOAuth2AccessToken();
|
||||
token.setOpenId("openid-existing");
|
||||
when(wxMpService.getOAuth2Service().getAccessToken("code-existing")).thenReturn(token);
|
||||
|
||||
WxOAuth2UserInfo userInfo = new WxOAuth2UserInfo();
|
||||
userInfo.setOpenid("openid-existing");
|
||||
userInfo.setNickname("Exists");
|
||||
userInfo.setHeadImgUrl("https://example.com/avatar.png");
|
||||
when(wxMpService.getOAuth2Service().getUserInfo(eq(token), eq(null))).thenReturn(userInfo);
|
||||
|
||||
PlayClerkUserInfoEntity existing = new PlayClerkUserInfoEntity();
|
||||
existing.setId("clerk-existing");
|
||||
existing.setOpenid("openid-existing");
|
||||
existing.setDeleted(false);
|
||||
existing.setAvatar("https://oss.example/already.jpg");
|
||||
|
||||
when(clerkUserInfoService.selectByOpenid("openid-existing")).thenReturn(existing);
|
||||
|
||||
String clerkId = wxOauthService.clerkUserLogin("code-existing");
|
||||
assertThat(clerkId).isEqualTo("clerk-existing");
|
||||
|
||||
verify(ossFileService, never()).upload(any(), anyString(), anyString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.starry.admin.modules.weichat.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
class WxTokenServiceTest {
|
||||
|
||||
@Test
|
||||
void tokenRoundTripSupportsBearerPrefix__covers_TOK_001() {
|
||||
WxTokenService service = new WxTokenService();
|
||||
ReflectionTestUtils.setField(service, "secret", "apitest-secret");
|
||||
ReflectionTestUtils.setField(service, "expireTime", 60);
|
||||
|
||||
String token = service.createWxUserToken("user-1");
|
||||
assertThat(service.getWxUserIdByToken(token)).isEqualTo("user-1");
|
||||
assertThat(service.getWxUserIdByToken("Bearer " + token)).isEqualTo("user-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenFailsToParse__covers_TOK_002() {
|
||||
WxTokenService service = new WxTokenService();
|
||||
ReflectionTestUtils.setField(service, "secret", "apitest-secret");
|
||||
ReflectionTestUtils.setField(service, "expireTime", -1);
|
||||
|
||||
String token = service.createWxUserToken("user-2");
|
||||
try {
|
||||
service.getWxUserIdByToken(token);
|
||||
} catch (ExpiredJwtException ex) {
|
||||
return;
|
||||
}
|
||||
throw new AssertionError("Expected ExpiredJwtException");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.starry.admin.modules.weichat.utils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class WxFileUtilsTest {
|
||||
|
||||
@Test
|
||||
void audioConvert2Mp3RejectsEmptyFile__covers_COM_006() throws Exception {
|
||||
File source = Files.createTempFile("apitest-empty-audio-", ".amr").toFile();
|
||||
File target = Files.createTempFile("apitest-empty-audio-", ".mp3").toFile();
|
||||
try {
|
||||
assertThatThrownBy(() -> WxFileUtils.audioConvert2Mp3(source, target))
|
||||
.isInstanceOf(CustomException.class)
|
||||
.hasMessage("音频文件上传失败");
|
||||
} finally {
|
||||
Files.deleteIfExists(source.toPath());
|
||||
Files.deleteIfExists(target.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.starry.admin.modules.withdraw.contract;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import java.lang.reflect.Field;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit-level contract tests that lock the intended public schema/API surface.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the adjustment system is implemented.</p>
|
||||
*/
|
||||
class EarningsAdjustmentSchemaContractTest {
|
||||
|
||||
@Test
|
||||
void earningsLineEntityExposesSourceTypeAndSourceId() throws Exception {
|
||||
assertHasField(EarningsLineEntity.class, "sourceType");
|
||||
assertHasField(EarningsLineEntity.class, "sourceId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentEntityAndEnumExist() {
|
||||
assertDoesNotThrow(() -> {
|
||||
Class<?> entity = Class.forName("com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity");
|
||||
assertHasField(entity, "id");
|
||||
assertHasField(entity, "tenantId");
|
||||
assertHasField(entity, "clerkId");
|
||||
assertHasField(entity, "amount");
|
||||
assertHasField(entity, "reasonType");
|
||||
assertHasField(entity, "reasonDescription");
|
||||
assertHasField(entity, "idempotencyKey");
|
||||
assertHasField(entity, "requestHash");
|
||||
assertHasField(entity, "status");
|
||||
assertHasField(entity, "effectiveTime");
|
||||
assertHasField(entity, "appliedTime");
|
||||
});
|
||||
assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType"));
|
||||
assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus"));
|
||||
}
|
||||
|
||||
private void assertHasField(Class<?> clazz, String fieldName) throws Exception {
|
||||
Field field = clazz.getDeclaredField(fieldName);
|
||||
assertThat(field).isNotNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.starry.admin.api.AbstractApiTest;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchLogMapper;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
class EarningsDeductionBatchServiceImplIdempotencyIntegrationTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private EarningsDeductionBatchServiceImpl batchService;
|
||||
|
||||
@Autowired
|
||||
private EarningsDeductionItemMapper itemMapper;
|
||||
|
||||
@Autowired
|
||||
private EarningsDeductionBatchLogMapper logMapper;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
private String batchId;
|
||||
private String clerkId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerkId = IdUtils.getUuid();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (batchId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
|
||||
List<String> adjustmentIds = items.stream()
|
||||
.map(EarningsDeductionItemEntity::getAdjustmentId)
|
||||
.filter(id -> id != null && !id.isBlank())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!adjustmentIds.isEmpty()) {
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, clerkId)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
|
||||
itemMapper.delete(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId));
|
||||
|
||||
logMapper.delete(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
|
||||
.eq(EarningsDeductionBatchLogEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsDeductionBatchLogEntity::getBatchId, batchId));
|
||||
|
||||
batchService.removeById(batchId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyOnce_canBeTriggeredTwiceWithoutBreakingIdempotency() {
|
||||
String tenantId = ApiTestDataSeeder.DEFAULT_TENANT_ID;
|
||||
LocalDateTime begin = LocalDateTime.now().minusDays(1);
|
||||
LocalDateTime end = LocalDateTime.now();
|
||||
String idempotencyKey = "e2e-deduct-idem-" + UUID.randomUUID();
|
||||
|
||||
EarningsDeductionBatchEntity batch = batchService.createOrGetProcessing(
|
||||
tenantId,
|
||||
List.of(clerkId),
|
||||
begin,
|
||||
end,
|
||||
EarningsDeductionRuleType.FIXED,
|
||||
new BigDecimal("1.00"),
|
||||
EarningsDeductionOperationType.PUNISHMENT,
|
||||
"idempotency test",
|
||||
idempotencyKey);
|
||||
assertNotNull(batch);
|
||||
batchId = batch.getId();
|
||||
|
||||
batchService.applyOnce(batchId);
|
||||
|
||||
EarningsDeductionItemEntity item = itemMapper.selectOne(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getClerkId, clerkId)
|
||||
.last("limit 1"));
|
||||
assertNotNull(item);
|
||||
assertTrue(item.getAdjustmentId() != null && !item.getAdjustmentId().isBlank());
|
||||
|
||||
assertDoesNotThrow(() -> batchService.applyOnce(batchId));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,10 @@ public class TypedR<T> implements Serializable {
|
||||
return new TypedR<>(ResultCodeEnum.SUCCESS.getCode(), true, msg, data);
|
||||
}
|
||||
|
||||
public static <T> TypedR<T> accepted(T data) {
|
||||
return new TypedR<>(202, true, "请求处理中", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list response from MyBatis-Plus page while flattening records/total/pageInfo.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user