后台子币商权限相关

This commit is contained in:
zhx 2026-07-09 13:02:40 +08:00
parent 5e9ed4c0c5
commit 50da0a569b
16 changed files with 1731 additions and 51 deletions

View File

@ -1577,7 +1577,7 @@
"operationId": "listCoinSellers",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CoinSellerPageResponse"
}
},
"x-permission": "coin-seller:view",
@ -1585,9 +1585,12 @@
},
"post": {
"operationId": "createCoinSeller",
"requestBody": {
"$ref": "#/components/requestBodies/CreateCoinSellerRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"201": {
"$ref": "#/components/responses/CoinSellerResponse"
}
},
"x-permission": "coin-seller:create",
@ -1597,9 +1600,12 @@
"/admin/coin-sellers/{user_id}/status": {
"patch": {
"operationId": "setCoinSellerStatus",
"requestBody": {
"$ref": "#/components/requestBodies/CoinSellerStatusRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CoinSellerResponse"
}
},
"parameters": [
@ -1770,9 +1776,18 @@
"/admin/countries": {
"get": {
"operationId": "listCountries",
"parameters": [
{
"in": "query",
"name": "enabled",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CountryListResponse"
}
},
"x-permission": "country:view",
@ -1780,9 +1795,12 @@
},
"post": {
"operationId": "createCountry",
"requestBody": {
"$ref": "#/components/requestBodies/CountryCreateRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"201": {
"$ref": "#/components/responses/CountryResponse"
}
},
"x-permission": "country:create",
@ -1794,7 +1812,7 @@
"operationId": "getCountry",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CountryResponse"
}
},
"parameters": [
@ -1812,9 +1830,12 @@
},
"patch": {
"operationId": "updateCountry",
"requestBody": {
"$ref": "#/components/requestBodies/CountryUpdateRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CountryResponse"
}
},
"parameters": [
@ -1834,7 +1855,7 @@
"operationId": "deleteCountry",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CountryResponse"
}
},
"parameters": [
@ -1851,12 +1872,37 @@
"x-permissions": ["country:status"]
}
},
"/admin/countries/{country_id}/rename-code": {
"post": {
"operationId": "renameCountryCode",
"requestBody": {
"$ref": "#/components/requestBodies/CountryRenameCodeRequest"
},
"responses": {
"201": {
"$ref": "#/components/responses/CountryCodeRenameJobResponse"
}
},
"parameters": [
{
"in": "path",
"name": "country_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "country:update",
"x-permissions": ["country:update"]
}
},
"/admin/countries/{country_id}/disable": {
"post": {
"operationId": "disableCountry",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CountryResponse"
}
},
"parameters": [
@ -1878,7 +1924,7 @@
"operationId": "enableCountry",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/CountryResponse"
}
},
"parameters": [
@ -6038,6 +6084,70 @@
"x-permissions": ["user:export"]
}
},
"/jobs": {
"get": {
"operationId": "listJobs",
"parameters": [
{
"$ref": "#/components/parameters/Page"
},
{
"$ref": "#/components/parameters/PageSize"
},
{
"$ref": "#/components/parameters/Keyword"
},
{
"in": "query",
"name": "status",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/AdminJobPageResponse"
}
},
"x-permission": "job:view",
"x-permissions": ["job:view"]
}
},
"/jobs/{id}": {
"get": {
"operationId": "getJob",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/AdminJobResponse"
}
},
"x-permission": "job:view",
"x-permissions": ["job:view", "country:update"]
}
},
"/jobs/{id}/cancel": {
"post": {
"operationId": "cancelJob",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "job:cancel",
"x-permissions": ["job:cancel"]
}
},
"/teams": {
"get": {
"operationId": "listTeams",
@ -6398,6 +6508,56 @@
}
}
},
"CreateCoinSellerRequest": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateCoinSellerPayload"
}
}
}
},
"CoinSellerStatusRequest": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CoinSellerStatusPayload"
}
}
}
},
"CountryCreateRequest": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CountryCreateInput"
}
}
}
},
"CountryRenameCodeRequest": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CountryRenameCodeInput"
}
}
}
},
"CountryUpdateRequest": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CountryUpdateInput"
}
}
}
},
"ChangePasswordRequest": {
"content": {
"application/json": {
@ -6681,6 +6841,26 @@
}
}
},
"CoinSellerPageResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCoinSellerPage"
}
}
}
},
"CoinSellerResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCoinSeller"
}
}
}
},
"BDProfilePageResponse": {
"description": "OK",
"content": {
@ -6691,6 +6871,56 @@
}
}
},
"AdminJobPageResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseAdminJobPage"
}
}
}
},
"AdminJobResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseAdminJob"
}
}
}
},
"CountryCodeRenameJobResponse": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCountryCodeRenameJob"
}
}
}
},
"CountryListResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCountryList"
}
}
}
},
"CountryResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCountry"
}
}
}
},
"CreateUserResponse": {
"description": "OK",
"content": {
@ -7004,6 +7234,27 @@
}
}
},
"ApiPageAdminJob": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AdminJob"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"ApiPageManager": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
@ -7025,6 +7276,27 @@
}
}
},
"ApiPageCoinSeller": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CoinSeller"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"ApiPageBDProfile": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
@ -7351,6 +7623,36 @@
}
]
},
"ApiResponseCoinSellerPage": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/ApiPageCoinSeller"
}
}
}
]
},
"ApiResponseCoinSeller": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/CoinSeller"
}
}
}
]
},
"ApiResponseBDProfilePage": {
"allOf": [
{
@ -7366,6 +7668,81 @@
}
]
},
"ApiResponseAdminJob": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/AdminJob"
}
}
}
]
},
"ApiResponseAdminJobPage": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/ApiPageAdminJob"
}
}
}
]
},
"ApiResponseCountry": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Country"
}
}
}
]
},
"ApiResponseCountryCodeRenameJob": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/CountryCodeRenameJob"
}
}
}
]
},
"ApiResponseCountryList": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/CountryList"
}
}
}
]
},
"ApiResponseDashboardOverview": {
"allOf": [
{
@ -7950,6 +8327,113 @@
}
}
},
"CoinSeller": {
"type": "object",
"required": ["userId"],
"properties": {
"avatar": {
"type": "string"
},
"canManageSubCoinSellers": {
"type": "boolean"
},
"contact": {
"type": "string"
},
"createdAtMs": {
"type": "integer"
},
"createdByUserId": {
"type": "integer"
},
"displayUserId": {
"type": "string"
},
"merchantAssetType": {
"type": "string"
},
"merchantBalance": {
"type": "integer"
},
"prettyDisplayUserId": {
"type": "string"
},
"prettyId": {
"type": "string"
},
"regionId": {
"type": "integer"
},
"regionName": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active", "disabled"]
},
"totalRechargeUsdtMicro": {
"type": "integer"
},
"updatedAtMs": {
"type": "integer"
},
"userId": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"CreateCoinSellerPayload": {
"type": "object",
"required": ["commandId", "targetUserId"],
"properties": {
"canManageSubCoinSellers": {
"type": "boolean"
},
"commandId": {
"type": "string",
"maxLength": 120
},
"contact": {
"type": "string",
"maxLength": 128
},
"reason": {
"type": "string",
"maxLength": 200
},
"targetUserId": {
"type": "string"
}
}
},
"CoinSellerStatusPayload": {
"type": "object",
"required": ["commandId", "status"],
"properties": {
"canManageSubCoinSellers": {
"type": "boolean"
},
"commandId": {
"type": "string",
"maxLength": 120
},
"contact": {
"type": "string",
"maxLength": 128
},
"reason": {
"type": "string",
"maxLength": 200
},
"status": {
"type": "string",
"enum": ["active", "disabled"]
}
}
},
"CreateManagerPayload": {
"type": "object",
"required": ["targetUserId"],
@ -8345,6 +8829,206 @@
}
]
},
"AdminJob": {
"type": "object",
"required": ["id", "type", "status"],
"properties": {
"id": {
"type": "integer"
},
"type": {
"type": "string"
},
"status": {
"type": "string"
},
"payloadJson": {
"type": "string"
},
"resultJson": {
"type": "string"
},
"error": {
"type": "string"
},
"attempt": {
"type": "integer"
},
"maxAttempts": {
"type": "integer"
},
"artifactPath": {
"type": "string"
},
"createdBy": {
"type": "integer"
},
"createdByName": {
"type": "string"
},
"startedAtMs": {
"nullable": true,
"type": "integer"
},
"finishedAtMs": {
"nullable": true,
"type": "integer"
},
"createdAtMs": {
"type": "integer"
},
"updatedAtMs": {
"type": "integer"
}
}
},
"Country": {
"type": "object",
"required": ["countryCode", "countryId"],
"properties": {
"countryCode": {
"type": "string"
},
"countryDisplayName": {
"type": "string"
},
"countryId": {
"type": "integer"
},
"countryName": {
"type": "string"
},
"createdAtMs": {
"type": "integer"
},
"enabled": {
"type": "boolean"
},
"flag": {
"type": "string"
},
"isoAlpha3": {
"type": "string"
},
"isoNumeric": {
"type": "string"
},
"phoneCountryCode": {
"type": "string"
},
"sortOrder": {
"type": "integer"
},
"updatedAtMs": {
"type": "integer"
}
}
},
"CountryBaseInput": {
"type": "object",
"required": ["countryDisplayName", "countryName"],
"properties": {
"countryDisplayName": {
"type": "string"
},
"countryName": {
"type": "string"
},
"flag": {
"type": "string"
},
"isoAlpha3": {
"type": "string"
},
"isoNumeric": {
"type": "string"
},
"phoneCountryCode": {
"type": "string"
},
"sortOrder": {
"type": "integer"
}
}
},
"CountryCreateInput": {
"allOf": [
{
"$ref": "#/components/schemas/CountryBaseInput"
},
{
"type": "object",
"required": ["countryCode"],
"properties": {
"countryCode": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
}
}
]
},
"CountryUpdateInput": {
"allOf": [
{
"$ref": "#/components/schemas/CountryBaseInput"
}
]
},
"CountryRenameCodeInput": {
"allOf": [
{
"$ref": "#/components/schemas/CountryBaseInput"
},
{
"type": "object",
"required": ["countryCode"],
"properties": {
"countryCode": {
"type": "string"
}
}
}
]
},
"CountryCodeRenameJob": {
"type": "object",
"required": ["jobId", "status", "countryId", "oldCountryCode", "newCountryCode"],
"properties": {
"jobId": {
"type": "integer"
},
"status": {
"type": "string"
},
"countryId": {
"type": "integer"
},
"oldCountryCode": {
"type": "string"
},
"newCountryCode": {
"type": "string"
}
}
},
"CountryList": {
"type": "object",
"required": ["items", "total"],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Country"
}
},
"total": {
"type": "integer"
}
}
},
"Log": {
"type": "object",
"additionalProperties": true

View File

@ -6,6 +6,8 @@ export const PERMISSIONS = {
userStatus: "user:status",
userResetPassword: "user:reset-password",
userExport: "user:export",
jobView: "job:view",
jobCancel: "job:cancel",
teamView: "team:view",
teamCreate: "team:create",
teamUpdate: "team:update",

View File

@ -18,6 +18,8 @@ import {
listHosts,
listManagers,
listRegions,
getJob,
renameCountryCode,
replaceRegionCountries,
setCoinSellerStatus,
updateBDLeaderPositionAlias,
@ -62,7 +64,13 @@ test("host org list APIs use generated admin paths and filters", async () => {
region_id: 7,
status: "active",
});
await createManager({ canBlockUser: false, canGrantBadge: false, canGrantVip: true, contact: "+63", targetUserId: "1003" });
await createManager({
canBlockUser: false,
canGrantBadge: false,
canGrantVip: true,
contact: "+63",
targetUserId: "1003",
});
await updateManager("1003", {
canBlockUser: false,
canGrantVehicle: true,
@ -212,6 +220,13 @@ test("country and region APIs use generated admin paths", async () => {
enabled: true,
});
await updateCountry(12, { countryDisplayName: "United States", countryName: "United States" });
await renameCountryCode(12, {
countryCode: "USA",
countryDisplayName: "United States",
countryName: "United States",
sortOrder: 10,
});
await getJob(99);
await listRegions({ status: "active" });
await createRegion({ countries: ["US"], name: "North America", regionCode: "NA" });
await replaceRegionCountries(7, { countries: ["US", "CA"] });
@ -220,10 +235,12 @@ test("country and region APIs use generated admin paths", async () => {
const [countryListUrl, countryListInit] = vi.mocked(fetch).mock.calls[0];
const [countryCreateUrl, countryCreateInit] = vi.mocked(fetch).mock.calls[1];
const [countryUpdateUrl, countryUpdateInit] = vi.mocked(fetch).mock.calls[2];
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[3];
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[4];
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[5];
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[6];
const [countryRenameUrl, countryRenameInit] = vi.mocked(fetch).mock.calls[3];
const [jobUrl, jobInit] = vi.mocked(fetch).mock.calls[4];
const [regionListUrl, regionListInit] = vi.mocked(fetch).mock.calls[5];
const [regionCreateUrl, regionCreateInit] = vi.mocked(fetch).mock.calls[6];
const [regionCountriesUrl, regionCountriesInit] = vi.mocked(fetch).mock.calls[7];
const [regionDisableUrl, regionDisableInit] = vi.mocked(fetch).mock.calls[8];
expect(String(countryListUrl)).toContain("/api/v1/admin/countries?");
expect(String(countryListUrl)).toContain("enabled=true");
@ -232,6 +249,12 @@ test("country and region APIs use generated admin paths", async () => {
expect(countryCreateInit?.method).toBe("POST");
expect(String(countryUpdateUrl)).toContain("/api/v1/admin/countries/12");
expect(countryUpdateInit?.method).toBe("PATCH");
expect(JSON.parse(String(countryUpdateInit?.body))).not.toHaveProperty("countryCode");
expect(String(countryRenameUrl)).toContain("/api/v1/admin/countries/12/rename-code");
expect(countryRenameInit?.method).toBe("POST");
expect(JSON.parse(String(countryRenameInit?.body))).toMatchObject({ countryCode: "USA", sortOrder: 10 });
expect(String(jobUrl)).toContain("/api/v1/jobs/99");
expect(jobInit?.method).toBe("GET");
expect(String(regionListUrl)).toContain("/api/v1/admin/regions?");
expect(String(regionListUrl)).toContain("status=active");
expect(regionListInit?.method).toBe("GET");

View File

@ -6,6 +6,7 @@ import type {
AgencyHostAddPayload,
AgencyJoinEnabledPayload,
AgencyStatusPayload,
AdminJobDto,
ApiList,
ApiPage,
BDLeaderPositionAliasPayload,
@ -19,8 +20,10 @@ import type {
CoinSellerStockDebitDto,
CoinSellerStockDebitPayload,
CoinSellerStatusPayload,
CountryCodeRenameJobDto,
CountryDto,
CountryPayload,
CountryRenameCodePayload,
CountryUpdatePayload,
CreateAgencyPayload,
CreateAgencyResultDto,
@ -78,6 +81,27 @@ export function updateCountry(countryId: EntityId, payload: CountryUpdatePayload
);
}
export function renameCountryCode(
countryId: EntityId,
payload: CountryRenameCodePayload,
): Promise<CountryCodeRenameJobDto> {
const endpoint = API_ENDPOINTS.renameCountryCode;
return apiRequest<CountryCodeRenameJobDto, CountryRenameCodePayload>(
apiEndpointPath(API_OPERATIONS.renameCountryCode, { country_id: countryId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function getJob(jobId: EntityId): Promise<AdminJobDto> {
const endpoint = API_ENDPOINTS.getJob;
return apiRequest<AdminJobDto>(apiEndpointPath(API_OPERATIONS.getJob, { id: jobId }), {
method: endpoint.method,
});
}
export function enableCountry(countryId: EntityId): Promise<CountryDto> {
const endpoint = API_ENDPOINTS.enableCountry;
return apiRequest<CountryDto>(apiEndpointPath(API_OPERATIONS.enableCountry, { country_id: countryId }), {
@ -192,10 +216,13 @@ export function createManager(payload: CreateManagerPayload): Promise<ManagerDto
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
const endpoint = API_ENDPOINTS.updateManager;
return apiRequest<ManagerDto, UpdateManagerPayload>(apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }), {
body: payload,
method: endpoint.method,
});
return apiRequest<ManagerDto, UpdateManagerPayload>(
apiEndpointPath(API_OPERATIONS.updateManager, { user_id: userId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {

View File

@ -23,8 +23,15 @@ import {
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyCreateForm = () => ({ commandId: makeCommandId("coin-seller"), contact: "", reason: "", targetUserId: "" });
const emptyCreateForm = () => ({
canManageSubCoinSellers: true,
commandId: makeCommandId("coin-seller"),
contact: "",
reason: "",
targetUserId: "",
});
const emptyEditForm = () => ({
canManageSubCoinSellers: true,
commandId: makeCommandId("coin-seller-status"),
contact: "",
reason: "",
@ -150,6 +157,7 @@ export function useHostCoinSellersPage() {
}
setSelectedSeller(seller);
setEditForm({
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
commandId: makeCommandId("coin-seller-status"),
contact: seller.contact || "",
reason: "update coin seller contact",
@ -265,6 +273,7 @@ export function useHostCoinSellersPage() {
`coin-seller-edit-${editForm.targetUserId || selectedSeller?.userId || ""}`,
"币商已更新",
async () => {
// 编辑接口复用 status 命令,提交时带上权限字段,避免只改联系方式时把子币商权限丢给后端默认值处理。
const payload = parseForm(coinSellerStatusSchema, editForm);
const { targetUserId, ...body } = payload;
await setCoinSellerStatus(targetUserId, body);
@ -286,7 +295,9 @@ export function useHostCoinSellersPage() {
const sellerID = seller.userId;
setContactSavingIds((current) => ({ ...current, [sellerID]: true }));
try {
// 联系方式行内保存同样走状态接口,显式保留当前权限位,避免局部保存覆盖行级权限。
await setCoinSellerStatus(sellerID, {
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
commandId: makeCommandId("coin-seller-contact"),
contact,
reason: "update coin seller contact",
@ -313,6 +324,37 @@ export function useHostCoinSellersPage() {
}
};
const toggleSubCoinSellerPermission = async (seller, nextEnabled = !seller?.canManageSubCoinSellers) => {
if (!abilities.canUpdate || !seller?.userId) {
return;
}
if ((seller.canManageSubCoinSellers === true) === nextEnabled) {
return;
}
await runAction(
`coin-seller-sub-permission-${seller.userId}`,
nextEnabled ? "子币商权限已开启" : "子币商权限已关闭",
async () => {
// 子权限开关复用币商状态接口,必须保留 status/contact防止权限更新被解释成状态重置。
await setCoinSellerStatus(seller.userId, {
canManageSubCoinSellers: nextEnabled,
commandId: makeCommandId("coin-seller-sub-permission"),
contact: seller.contact || "",
reason: "update coin seller sub permission",
status: seller.status || "active",
});
setSellerRowPatches((current) => ({
...current,
[seller.userId]: {
...(current[seller.userId] || {}),
canManageSubCoinSellers: nextEnabled,
},
}));
await reload();
},
);
};
const submitStockCredit = async (event) => {
event.preventDefault();
if (!selectedSeller?.userId) {
@ -373,7 +415,9 @@ export function useHostCoinSellersPage() {
return;
}
await runAction(`coin-seller-status-${seller.userId}`, nextEnabled ? "币商已启用" : "币商已停用", async () => {
// 状态开关只改变启停状态,显式保留权限位,避免后端按缺省值改写子币商授权。
await setCoinSellerStatus(seller.userId, {
canManageSubCoinSellers: seller.canManageSubCoinSellers === true,
commandId: makeCommandId("coin-seller-status"),
reason: "",
status: nextStatus,
@ -444,6 +488,7 @@ export function useHostCoinSellersPage() {
submitEditSeller,
submitStockCredit,
submitStockDebit,
toggleSubCoinSellerPermission,
toggleSeller,
updateRateTier,
userFilter,

View File

@ -2,9 +2,17 @@ import { useCallback, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createCountry, disableCountry, enableCountry, listCountries, updateCountry } from "@/features/host-org/api";
import {
createCountry,
disableCountry,
enableCountry,
getJob,
listCountries,
renameCountryCode,
updateCountry,
} from "@/features/host-org/api";
import { useCountryAbilities } from "@/features/host-org/permissions.js";
import { countryCreateSchema, countryUpdateSchema } from "@/features/host-org/schema";
import { countryCreateSchema, countryRenameCodeSchema, countryUpdateSchema } from "@/features/host-org/schema";
const emptyCountryForm = () => ({
countryCode: "",
@ -86,9 +94,30 @@ export function useHostCountriesPage() {
const submitCountry = async (event) => {
event.preventDefault();
const isEdit = activeAction === "edit";
await runAction(isEdit ? "country-edit" : "country-create", isEdit ? "国家已更新" : "国家已创建", async () => {
const payload = parseForm(isEdit ? countryUpdateSchema : countryCreateSchema, countryForm);
const data = isEdit ? await updateCountry(editingCountry.countryId, payload) : await createCountry(payload);
if (!isEdit) {
await runAction("country-create", "国家已创建", async () => {
const payload = parseForm(countryCreateSchema, countryForm);
const data = await createCountry(payload);
setCountryForm(emptyCountryForm());
setEditingCountry(null);
setActiveAction("");
await reload();
return data;
});
return;
}
const currentCountryCode = normalizeCountryCode(editingCountry?.countryCode);
const nextCountryCode = normalizeCountryCode(countryForm.countryCode);
if (currentCountryCode !== nextCountryCode) {
await runCountryCodeRename();
return;
}
await runAction("country-edit", "国家已更新", async () => {
const { countryCode: _countryCode, enabled: _enabled, ...formWithoutImmutableFields } = countryForm;
const payload = parseForm(countryUpdateSchema, formWithoutImmutableFields);
const data = await updateCountry(editingCountry.countryId, payload);
setCountryForm(emptyCountryForm());
setEditingCountry(null);
setActiveAction("");
@ -97,6 +126,26 @@ export function useHostCountriesPage() {
});
};
const runCountryCodeRename = async () => {
setLoadingAction("country-rename");
try {
const payload = parseForm(countryRenameCodeSchema, countryForm);
const job = await renameCountryCode(editingCountry.countryId, payload);
showToast(`国家码重命名任务已创建:#${job.jobId}`, "success");
setCountryForm(emptyCountryForm());
setEditingCountry(null);
setActiveAction("");
// 国家码会同步 user/admin/room 多个当前态,只有 job succeeded 后才刷新列表,避免短暂显示新旧码混杂。
await waitCountryCodeRenameJob(job.jobId);
await reload();
showToast("国家码重命名完成", "success");
} catch (err) {
showToast(err.message || "国家码重命名失败", "error");
} finally {
setLoadingAction("");
}
};
const toggleCountry = async (country, nextEnabled = !country.enabled) => {
if (!abilities.canStatus || Boolean(country.enabled) === nextEnabled) {
return;
@ -144,6 +193,33 @@ export function useHostCountriesPage() {
};
}
async function waitCountryCodeRenameJob(jobId) {
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
const job = await getJob(jobId);
if (job.status === "succeeded") {
return job;
}
if (job.status === "failed" || job.status === "canceled") {
throw new Error(job.error || "国家码重命名任务失败");
}
await delay(1500);
}
throw new Error("国家码重命名任务仍在处理中,请稍后刷新查看");
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function normalizeCountryCode(value) {
return String(value || "")
.trim()
.toUpperCase();
}
function filterCountries(items, query) {
const keyword = query.trim().toLowerCase();
if (!keyword) {

View File

@ -0,0 +1,127 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { useHostCountriesPage } from "./useHostCountriesPage.js";
import { getJob, renameCountryCode, updateCountry } from "@/features/host-org/api";
import { useCountryAbilities } from "@/features/host-org/permissions.js";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const mocks = vi.hoisted(() => ({
reload: vi.fn(),
showToast: vi.fn(),
}));
vi.mock("@/features/host-org/api", () => ({
createCountry: vi.fn(),
disableCountry: vi.fn(),
enableCountry: vi.fn(),
getJob: vi.fn(),
listCountries: vi.fn(),
renameCountryCode: vi.fn(),
updateCountry: vi.fn(),
}));
vi.mock("@/features/host-org/permissions.js", () => ({
useCountryAbilities: vi.fn(),
}));
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
useAdminQuery: vi.fn(),
}));
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
useToast: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("country edit without code change uses metadata update endpoint", async () => {
mockHookDeps();
vi.mocked(updateCountry).mockResolvedValue({ countryId: 12, countryCode: "PH" });
const { result } = renderHook(() => useHostCountriesPage());
act(() => {
result.current.openEditCountry(countryFixture());
});
await act(async () => {
await result.current.submitCountry({ preventDefault: vi.fn() });
});
expect(updateCountry).toHaveBeenCalledWith(
12,
expect.not.objectContaining({
countryCode: expect.anything(),
}),
);
expect(renameCountryCode).not.toHaveBeenCalled();
expect(mocks.reload).toHaveBeenCalled();
});
test("country edit with code change creates rename job and polls until success", async () => {
mockHookDeps();
vi.mocked(renameCountryCode).mockResolvedValue({
countryId: 12,
jobId: 99,
newCountryCode: "PHL",
oldCountryCode: "PH",
status: "pending",
});
vi.mocked(getJob).mockResolvedValue({ id: 99, status: "succeeded", type: "country-code-rename" });
const { result } = renderHook(() => useHostCountriesPage());
act(() => {
result.current.openEditCountry(countryFixture());
});
act(() => {
result.current.setCountryForm({ ...result.current.countryForm, countryCode: "phl" });
});
await act(async () => {
await result.current.submitCountry({ preventDefault: vi.fn() });
});
expect(renameCountryCode).toHaveBeenCalledWith(
12,
expect.objectContaining({
countryCode: "PHL",
countryDisplayName: "菲律宾",
countryName: "Philippines",
}),
);
expect(getJob).toHaveBeenCalledWith(99);
expect(updateCountry).not.toHaveBeenCalled();
expect(mocks.showToast).toHaveBeenCalledWith("国家码重命名完成", "success");
expect(mocks.reload).toHaveBeenCalled();
});
function mockHookDeps() {
vi.mocked(useCountryAbilities).mockReturnValue({
canCreate: true,
canStatus: true,
canUpdate: true,
canView: true,
});
vi.mocked(useToast).mockReturnValue({ showToast: mocks.showToast });
vi.mocked(useAdminQuery).mockReturnValue({
data: { items: [], total: 0 },
error: null,
loading: false,
reload: mocks.reload,
});
}
function countryFixture() {
return {
countryCode: "PH",
countryDisplayName: "菲律宾",
countryId: 12,
countryName: "Philippines",
enabled: true,
flag: "🇵🇭",
isoAlpha3: "PHL",
isoNumeric: "608",
phoneCountryCode: "+63",
sortOrder: 1770,
};
}

View File

@ -2,6 +2,7 @@ import Add from "@mui/icons-material/Add";
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
@ -16,6 +17,7 @@ import {
AdminFormFieldGrid,
AdminFormReadOnlyField,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
@ -91,13 +93,19 @@ export function HostCoinSellersPage() {
render: (item) => <SellerStatusSwitch item={item} page={page} />,
width: "minmax(90px, 0.6fr)",
},
{
key: "canManageSubCoinSellers",
label: "子币商权限",
render: (item) => <SubCoinSellerPermissionSwitch item={item} page={page} />,
width: "minmax(120px, 0.7fr)",
},
...baseColumns.slice(2),
page.abilities.canStockCredit || page.abilities.canLedger
page.abilities.canUpdate || page.abilities.canStockCredit || page.abilities.canLedger
? {
key: "actions",
label: "操作",
render: (item) => <SellerActions item={item} page={page} />,
width: "minmax(130px, 0.65fr)",
width: "minmax(160px, 0.75fr)",
}
: null,
]
@ -167,7 +175,7 @@ export function HostCoinSellersPage() {
columns={columns}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="930px"
minWidth="1080px"
pagination={
total > 0
? {
@ -208,6 +216,14 @@ export function HostCoinSellersPage() {
value={page.createForm.contact}
onChange={(event) => page.setCreateForm({ ...page.createForm, contact: event.target.value })}
/>
<AdminFormSwitchField
checked={page.createForm.canManageSubCoinSellers !== false}
disabled={createDisabled}
label="子币商权限"
onChange={(checked) =>
page.setCreateForm({ ...page.createForm, canManageSubCoinSellers: checked })
}
/>
</HostOrgActionModal>
<HostOrgActionModal
@ -226,6 +242,12 @@ export function HostCoinSellersPage() {
value={page.editForm.contact}
onChange={(event) => page.setEditForm({ ...page.editForm, contact: event.target.value })}
/>
<AdminFormSwitchField
checked={page.editForm.canManageSubCoinSellers === true}
disabled={updateDisabled}
label="子币商权限"
onChange={(checked) => page.setEditForm({ ...page.editForm, canManageSubCoinSellers: checked })}
/>
</HostOrgActionModal>
<HostOrgActionModal
@ -482,6 +504,21 @@ function SellerStatusSwitch({ item, page }) {
);
}
function SubCoinSellerPermissionSwitch({ item, page }) {
return (
<AdminSwitch
checked={item.canManageSubCoinSellers === true}
checkedLabel="开"
disabled={
!page.abilities.canUpdate || page.loadingAction === `coin-seller-sub-permission-${item.userId}`
}
inputProps={{ "aria-label": item.canManageSubCoinSellers === true ? "关闭子币商权限" : "开启子币商权限" }}
uncheckedLabel="关"
onChange={(event) => page.toggleSubCoinSellerPermission(item, event.target.checked)}
/>
);
}
function SellerContact({ item, page }) {
const contact = String(item.contact || "").trim();
return (
@ -505,6 +542,15 @@ function SellerContact({ item, page }) {
function SellerActions({ item, page }) {
return (
<AdminRowActions>
{page.abilities.canUpdate ? (
<AdminActionIconButton
disabled={page.loadingAction === `coin-seller-edit-${item.userId}`}
label="编辑币商"
onClick={() => page.openEditSeller(item)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canLedger ? (
<AdminActionIconButton
label="币商流水"

View File

@ -6,6 +6,7 @@ import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSel
const mocks = vi.hoisted(() => ({
ledgerTable: vi.fn(),
openSellerLedger: vi.fn(),
toggleSubCoinSellerPermission: vi.fn(),
}));
vi.mock("@/features/host-org/hooks/useHostCoinSellersPage.js", () => ({
@ -41,9 +42,29 @@ test("coin seller table shows cumulative USDT and hides creator id column", () =
expect(screen.queryByText("创建人 ID")).not.toBeInTheDocument();
expect(screen.getByText("累充USDT")).toBeInTheDocument();
expect(screen.getByText("子币商权限")).toBeInTheDocument();
expect(screen.getByText("12.5")).toBeInTheDocument();
});
test("coin seller sub permission switch toggles through the page hook", () => {
const seller = sellerFixture();
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
abilities: { ...pageFixture().abilities, canUpdate: true },
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
}),
);
render(<HostCoinSellersPage />);
const switchInput = screen.getByRole("switch", { name: "关闭子币商权限" });
expect(switchInput).toBeChecked();
fireEvent.click(switchInput);
expect(mocks.toggleSubCoinSellerPermission).toHaveBeenCalledWith(seller, false);
});
test("coin seller balance and cumulative USDT headers request sorting", () => {
const changeSort = vi.fn();
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ changeSort }));
@ -86,6 +107,56 @@ test("coin seller ledger drawer passes locked seller id to shared table", () =>
expect(mocks.ledgerTable).toHaveBeenCalledWith(expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }));
});
test("coin seller create form includes sub permission switch", () => {
const setCreateForm = vi.fn();
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
abilities: { ...pageFixture().abilities, canCreate: true },
activeAction: "create",
createForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
setCreateForm,
}),
);
render(<HostCoinSellersPage />);
const switchInput = screen.getByRole("switch", { name: "子币商权限" });
expect(switchInput).toBeChecked();
fireEvent.click(switchInput);
expect(setCreateForm).toHaveBeenCalledWith(
expect.objectContaining({
canManageSubCoinSellers: false,
}),
);
});
test("coin seller edit form preserves sub permission field", () => {
const setEditForm = vi.fn();
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
abilities: { ...pageFixture().abilities, canUpdate: true },
activeAction: "edit",
editForm: { canManageSubCoinSellers: false, contact: "+63", targetUserId: "3001" },
setEditForm,
}),
);
render(<HostCoinSellersPage />);
const switchInput = screen.getByRole("switch", { name: "子币商权限" });
expect(switchInput).not.toBeChecked();
fireEvent.click(switchInput);
expect(setEditForm).toHaveBeenCalledWith(
expect.objectContaining({
canManageSubCoinSellers: true,
}),
);
});
function pageFixture(patch = {}) {
return {
abilities: {
@ -105,9 +176,9 @@ function pageFixture(patch = {}) {
changeStatus: vi.fn(),
closeAction: vi.fn(),
contactSavingIds: {},
createForm: { contact: "", targetUserId: "" },
createForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
editForm: { contact: "", targetUserId: "" },
editForm: { canManageSubCoinSellers: true, contact: "", targetUserId: "" },
error: null,
loading: false,
loadingAction: "",
@ -142,6 +213,7 @@ function pageFixture(patch = {}) {
submitEditSeller: vi.fn(),
submitRateSettings: vi.fn(),
submitStockCredit: vi.fn(),
toggleSubCoinSellerPermission: mocks.toggleSubCoinSellerPermission,
toggleSeller: vi.fn(),
updateRateTier: vi.fn(),
...patch,
@ -151,6 +223,7 @@ function pageFixture(patch = {}) {
function sellerFixture() {
return {
avatar: "",
canManageSubCoinSellers: true,
contact: "菲律賓 +63",
displayUserId: "164425",
merchantAssetType: "COIN_SELLER_COIN",

View File

@ -108,7 +108,11 @@ export function HostCountriesPage() {
<HostOrgActionModal
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
loading={page.loadingAction === "country-create" || page.loadingAction === "country-edit"}
loading={
page.loadingAction === "country-create" ||
page.loadingAction === "country-edit" ||
page.loadingAction === "country-rename"
}
open={page.activeAction === "create" || page.activeAction === "edit"}
sectionTitle="国家信息"
onClose={page.closeAction}
@ -116,7 +120,7 @@ export function HostCountriesPage() {
title={page.activeAction === "edit" ? "编辑国家" : "创建国家"}
>
<TextField
disabled={page.activeAction === "edit" || createDisabled}
disabled={page.activeAction === "edit" ? updateDisabled : createDisabled}
label="国家码"
required
value={page.countryForm.countryCode}

View File

@ -0,0 +1,94 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { HostCountriesPage } from "./HostCountriesPage.jsx";
import { useHostCountriesPage } from "@/features/host-org/hooks/useHostCountriesPage.js";
const mocks = vi.hoisted(() => ({
setCountryForm: vi.fn(),
}));
vi.mock("@/features/host-org/hooks/useHostCountriesPage.js", () => ({
useHostCountriesPage: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("country code field is editable when editing a country with update permission", () => {
vi.mocked(useHostCountriesPage).mockReturnValue(pageFixture());
render(<HostCountriesPage />);
const countryCodeInput = screen.getByRole("textbox", { name: /国家码/ });
expect(countryCodeInput).not.toBeDisabled();
fireEvent.change(countryCodeInput, { target: { value: "PHL" } });
expect(mocks.setCountryForm).toHaveBeenCalledWith(expect.objectContaining({ countryCode: "PHL" }));
});
test("country code field stays disabled when editing without update permission", () => {
vi.mocked(useHostCountriesPage).mockReturnValue(
pageFixture({
abilities: { ...pageFixture().abilities, canUpdate: false },
}),
);
render(<HostCountriesPage />);
expect(screen.getByRole("textbox", { name: /国家码/ })).toBeDisabled();
});
function pageFixture(patch = {}) {
return {
abilities: {
canCreate: true,
canStatus: true,
canUpdate: true,
canView: true,
},
activeAction: "edit",
changeEnabledStatus: vi.fn(),
changeQuery: vi.fn(),
closeAction: vi.fn(),
countryForm: countryFormFixture(),
data: { items: [], total: 0 },
editingCountry: countryFixture(),
enabledStatus: "",
error: null,
loading: false,
loadingAction: "",
openCreateCountry: vi.fn(),
openEditCountry: vi.fn(),
query: "",
reload: vi.fn(),
resetFilters: vi.fn(),
setCountryForm: mocks.setCountryForm,
submitCountry: vi.fn(),
toggleCountry: vi.fn(),
...patch,
};
}
function countryFormFixture() {
return {
countryCode: "PH",
countryDisplayName: "菲律宾",
countryName: "Philippines",
enabled: true,
flag: "🇵🇭",
isoAlpha3: "PHL",
isoNumeric: "608",
phoneCountryCode: "+63",
sortOrder: 1770,
};
}
function countryFixture() {
return {
...countryFormFixture(),
countryId: 12,
updatedAtMs: 1783578249000,
};
}

View File

@ -1,5 +1,13 @@
import { expect, test } from "vitest";
import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema, createManagerSchema } from "./schema";
import {
agencyCloseSchema,
agencyJoinEnabledSchema,
coinSellerStatusSchema,
createCoinSellerSchema,
createManagerSchema,
countryRenameCodeSchema,
countryUpdateSchema,
} from "./schema";
test("coin seller contact accepts plus sign with large internal user id", () => {
const result = coinSellerStatusSchema.safeParse({
@ -17,6 +25,37 @@ test("coin seller contact accepts plus sign with large internal user id", () =>
}
});
test("coin seller schemas preserve sub seller permission defaults and explicit false", () => {
const createDefault = createCoinSellerSchema.safeParse({
commandId: "coin-seller-create-test",
targetUserId: "1003",
});
const statusDefault = coinSellerStatusSchema.safeParse({
commandId: "coin-seller-edit-test",
status: "active",
targetUserId: "320756743338463232",
});
const statusDisabled = coinSellerStatusSchema.safeParse({
canManageSubCoinSellers: false,
commandId: "coin-seller-edit-test",
status: "active",
targetUserId: "320756743338463232",
});
expect(createDefault.success).toBe(true);
expect(statusDefault.success).toBe(true);
expect(statusDisabled.success).toBe(true);
if (createDefault.success) {
expect(createDefault.data.canManageSubCoinSellers).toBe(true);
}
if (statusDefault.success) {
expect(statusDefault.data).not.toHaveProperty("canManageSubCoinSellers");
}
if (statusDisabled.success) {
expect(statusDisabled.data.canManageSubCoinSellers).toBe(false);
}
});
test("agency action schemas preserve large agency ids as strings", () => {
const agencyId = "321170072154411009";
const closeResult = agencyCloseSchema.safeParse({
@ -41,6 +80,36 @@ test("agency action schemas preserve large agency ids as strings", () => {
}
});
test("country update schema excludes country code from metadata update payload", () => {
const result = countryUpdateSchema.safeParse({
countryCode: "phl",
countryDisplayName: "Philippines Display",
countryName: "Philippines",
sortOrder: "1770",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).not.toHaveProperty("countryCode");
expect(result.data).not.toHaveProperty("enabled");
}
});
test("country rename schema keeps editable country code", () => {
const result = countryRenameCodeSchema.safeParse({
countryCode: "phl",
countryDisplayName: "Philippines Display",
countryName: "Philippines",
sortOrder: "1770",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.countryCode).toBe("PHL");
expect(result.data).not.toHaveProperty("enabled");
}
});
test("manager schema defaults vip grant permission on and preserves explicit false", () => {
const defaultResult = createManagerSchema.safeParse({ targetUserId: "1003" });
const disabledResult = createManagerSchema.safeParse({ canGrantVip: false, targetUserId: "1003" });

View File

@ -105,6 +105,7 @@ export const bdStatusSchema = commandBaseSchema.extend({
});
export const createCoinSellerSchema = commandContactSchema.extend({
canManageSubCoinSellers: z.boolean().optional().default(true),
targetUserId: userIdSchema,
});
@ -124,6 +125,7 @@ export const createManagerSchema = z.object({
});
export const coinSellerStatusSchema = commandContactSchema.extend({
canManageSubCoinSellers: z.boolean().optional(),
status: z.enum(["active", "disabled"]),
targetUserId: entityIdSchema,
});
@ -200,6 +202,10 @@ export const countryUpdateSchema = countryCreateSchema.omit({
enabled: true,
});
export const countryRenameCodeSchema = countryCreateSchema.omit({
enabled: true,
});
export const regionCreateSchema = regionBaseSchema;
export const regionUpdateSchema = regionBaseSchema;
@ -221,6 +227,7 @@ export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
export type CountryCreateForm = z.infer<typeof countryCreateSchema>;
export type CountryUpdateForm = z.infer<typeof countryUpdateSchema>;
export type CountryRenameCodeForm = z.infer<typeof countryRenameCodeSchema>;
export type RegionCreateForm = z.infer<typeof regionCreateSchema>;
export type RegionUpdateForm = z.infer<typeof regionUpdateSchema>;
export type RegionCountriesForm = z.infer<typeof regionCountriesSchema>;

View File

@ -29,6 +29,7 @@ export const API_OPERATIONS = {
batchCreateGifts: "batchCreateGifts",
batchDeleteResources: "batchDeleteResources",
batchUpdateUserStatus: "batchUpdateUserStatus",
cancelJob: "cancelJob",
cancelRoomPin: "cancelRoomPin",
changePassword: "changePassword",
closeAgency: "closeAgency",
@ -124,6 +125,7 @@ export const API_OPERATIONS = {
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
getHumanRoomRobotConfig: "getHumanRoomRobotConfig",
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
getJob: "getJob",
getLuckyGiftConfig: "getLuckyGiftConfig",
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
getRechargeBillOverview: "getRechargeBillOverview",
@ -191,6 +193,7 @@ export const API_OPERATIONS = {
listHostSalarySettlements: "listHostSalarySettlements",
listHostWithdrawals: "listHostWithdrawals",
listInviteActivityRewardClaims: "listInviteActivityRewardClaims",
listJobs: "listJobs",
listLevelConfig: "listLevelConfig",
listLoginLogs: "listLoginLogs",
listLuckyGiftConfigs: "listLuckyGiftConfigs",
@ -257,6 +260,7 @@ export const API_OPERATIONS = {
refreshGoogleRechargePaid: "refreshGoogleRechargePaid",
rejectFinanceApplication: "rejectFinanceApplication",
rejectFinanceWithdrawalApplication: "rejectFinanceWithdrawalApplication",
renameCountryCode: "renameCountryCode",
replaceCoinSellerSalaryRates: "replaceCoinSellerSalaryRates",
replaceRegionBlocks: "replaceRegionBlocks",
replaceRegionCountries: "replaceRegionCountries",
@ -468,6 +472,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "user:status",
permissions: ["user:status"]
},
cancelJob: {
method: "POST",
operationId: API_OPERATIONS.cancelJob,
path: "/v1/jobs/{id}/cancel",
permission: "job:cancel",
permissions: ["job:cancel"]
},
cancelRoomPin: {
method: "DELETE",
operationId: API_OPERATIONS.cancelRoomPin,
@ -1131,6 +1142,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "invite-activity-reward:view",
permissions: ["invite-activity-reward:view"]
},
getJob: {
method: "GET",
operationId: API_OPERATIONS.getJob,
path: "/v1/jobs/{id}",
permission: "job:view",
permissions: ["job:view","country:update"]
},
getLuckyGiftConfig: {
method: "GET",
operationId: API_OPERATIONS.getLuckyGiftConfig,
@ -1598,6 +1616,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "invite-activity-reward:view",
permissions: ["invite-activity-reward:view"]
},
listJobs: {
method: "GET",
operationId: API_OPERATIONS.listJobs,
path: "/v1/jobs",
permission: "job:view",
permissions: ["job:view"]
},
listLevelConfig: {
method: "GET",
operationId: API_OPERATIONS.listLevelConfig,
@ -2050,6 +2075,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "finance-withdrawal:audit",
permissions: ["finance-withdrawal:audit","finance-application:audit"]
},
renameCountryCode: {
method: "POST",
operationId: API_OPERATIONS.renameCountryCode,
path: "/v1/admin/countries/{country_id}/rename-code",
permission: "country:update",
permissions: ["country:update"]
},
replaceCoinSellerSalaryRates: {
method: "PUT",
operationId: API_OPERATIONS.replaceCoinSellerSalaryRates,

View File

@ -1204,6 +1204,22 @@ export interface paths {
patch: operations["updateCountry"];
trace?: never;
};
"/admin/countries/{country_id}/rename-code": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["renameCountryCode"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/countries/{country_id}/disable": {
parameters: {
query?: never;
@ -3812,6 +3828,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/jobs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listJobs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/jobs/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getJob"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/jobs/{id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["cancelJob"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/teams": {
parameters: {
query?: never;
@ -3982,12 +4046,24 @@ export interface components {
pageSize: number;
total: number;
};
ApiPageAdminJob: {
items: components["schemas"]["AdminJob"][];
page: number;
pageSize: number;
total: number;
};
ApiPageManager: {
items: components["schemas"]["Manager"][];
page: number;
pageSize: number;
total: number;
};
ApiPageCoinSeller: {
items: components["schemas"]["CoinSeller"][];
page: number;
pageSize: number;
total: number;
};
ApiPageBDProfile: {
items: components["schemas"]["BDProfile"][];
page: number;
@ -4135,9 +4211,30 @@ export interface components {
ApiResponseManager: components["schemas"]["Envelope"] & {
data?: components["schemas"]["Manager"];
};
ApiResponseCoinSellerPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageCoinSeller"];
};
ApiResponseCoinSeller: components["schemas"]["Envelope"] & {
data?: components["schemas"]["CoinSeller"];
};
ApiResponseBDProfilePage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageBDProfile"];
};
ApiResponseAdminJob: components["schemas"]["Envelope"] & {
data?: components["schemas"]["AdminJob"];
};
ApiResponseAdminJobPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageAdminJob"];
};
ApiResponseCountry: components["schemas"]["Envelope"] & {
data?: components["schemas"]["Country"];
};
ApiResponseCountryCodeRenameJob: components["schemas"]["Envelope"] & {
data?: components["schemas"]["CountryCodeRenameJob"];
};
ApiResponseCountryList: components["schemas"]["Envelope"] & {
data?: components["schemas"]["CountryList"];
};
ApiResponseDashboardOverview: components["schemas"]["Envelope"] & {
data?: components["schemas"]["DashboardOverview"];
};
@ -4275,6 +4372,41 @@ export interface components {
userId: string;
username?: string;
};
CoinSeller: {
avatar?: string;
canManageSubCoinSellers?: boolean;
contact?: string;
createdAtMs?: number;
createdByUserId?: number;
displayUserId?: string;
merchantAssetType?: string;
merchantBalance?: number;
prettyDisplayUserId?: string;
prettyId?: string;
regionId?: number;
regionName?: string;
/** @enum {string} */
status?: "active" | "disabled";
totalRechargeUsdtMicro?: number;
updatedAtMs?: number;
userId: string;
username?: string;
};
CreateCoinSellerPayload: {
canManageSubCoinSellers?: boolean;
commandId: string;
contact?: string;
reason?: string;
targetUserId: string;
};
CoinSellerStatusPayload: {
canManageSubCoinSellers?: boolean;
commandId: string;
contact?: string;
reason?: string;
/** @enum {string} */
status: "active" | "disabled";
};
CreateManagerPayload: {
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
@ -4406,6 +4538,65 @@ export interface components {
updatedByAdminId?: number;
};
HumanRoomRobotConfigInput: components["schemas"]["HumanRoomRobotConfig"];
AdminJob: {
id: number;
type: string;
status: string;
payloadJson?: string;
resultJson?: string;
error?: string;
attempt?: number;
maxAttempts?: number;
artifactPath?: string;
createdBy?: number;
createdByName?: string;
startedAtMs?: number | null;
finishedAtMs?: number | null;
createdAtMs?: number;
updatedAtMs?: number;
};
Country: {
countryCode: string;
countryDisplayName?: string;
countryId: number;
countryName?: string;
createdAtMs?: number;
enabled?: boolean;
flag?: string;
isoAlpha3?: string;
isoNumeric?: string;
phoneCountryCode?: string;
sortOrder?: number;
updatedAtMs?: number;
};
CountryBaseInput: {
countryDisplayName: string;
countryName: string;
flag?: string;
isoAlpha3?: string;
isoNumeric?: string;
phoneCountryCode?: string;
sortOrder?: number;
};
CountryCreateInput: components["schemas"]["CountryBaseInput"] & {
countryCode: string;
enabled?: boolean;
};
CountryUpdateInput: components["schemas"]["CountryBaseInput"];
CountryRenameCodeInput: components["schemas"]["CountryBaseInput"] & {
countryCode: string;
};
CountryCodeRenameJob: {
jobId: number;
status: string;
countryId: number;
oldCountryCode: string;
newCountryCode: string;
};
CountryList: {
items: components["schemas"]["Country"][];
total: number;
};
Log: {
[key: string]: unknown;
};
@ -4662,6 +4853,24 @@ export interface components {
};
};
/** @description OK */
CoinSellerPageResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseCoinSellerPage"];
};
};
/** @description OK */
CoinSellerResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseCoinSeller"];
};
};
/** @description OK */
BDProfilePageResponse: {
headers: {
[name: string]: unknown;
@ -4671,6 +4880,51 @@ export interface components {
};
};
/** @description OK */
AdminJobPageResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAdminJobPage"];
};
};
/** @description OK */
AdminJobResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseAdminJob"];
};
};
/** @description Created */
CountryCodeRenameJobResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseCountryCodeRenameJob"];
};
};
/** @description OK */
CountryListResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseCountryList"];
};
};
/** @description OK */
CountryResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseCountry"];
};
};
/** @description OK */
CreateUserResponse: {
headers: {
[name: string]: unknown;
@ -4966,6 +5220,31 @@ export interface components {
};
};
};
CreateCoinSellerRequest: {
content: {
"application/json": components["schemas"]["CreateCoinSellerPayload"];
};
};
CoinSellerStatusRequest: {
content: {
"application/json": components["schemas"]["CoinSellerStatusPayload"];
};
};
CountryCreateRequest: {
content: {
"application/json": components["schemas"]["CountryCreateInput"];
};
};
CountryRenameCodeRequest: {
content: {
"application/json": components["schemas"]["CountryRenameCodeInput"];
};
};
CountryUpdateRequest: {
content: {
"application/json": components["schemas"]["CountryUpdateInput"];
};
};
ChangePasswordRequest: {
content: {
"application/json": {
@ -6417,7 +6696,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CoinSellerPageResponse"];
};
};
createCoinSeller: {
@ -6427,9 +6706,9 @@ export interface operations {
path?: never;
cookie?: never;
};
requestBody?: never;
requestBody: components["requestBodies"]["CreateCoinSellerRequest"];
responses: {
200: components["responses"]["EmptyResponse"];
201: components["responses"]["CoinSellerResponse"];
};
};
setCoinSellerStatus: {
@ -6441,9 +6720,9 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: components["requestBodies"]["CoinSellerStatusRequest"];
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CoinSellerResponse"];
};
};
creditCoinSellerStock: {
@ -6526,14 +6805,16 @@ export interface operations {
};
listCountries: {
parameters: {
query?: never;
query?: {
enabled?: boolean;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CountryListResponse"];
};
};
createCountry: {
@ -6543,9 +6824,9 @@ export interface operations {
path?: never;
cookie?: never;
};
requestBody?: never;
requestBody: components["requestBodies"]["CountryCreateRequest"];
responses: {
200: components["responses"]["EmptyResponse"];
201: components["responses"]["CountryResponse"];
};
};
getCountry: {
@ -6559,7 +6840,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CountryResponse"];
};
};
deleteCountry: {
@ -6573,7 +6854,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CountryResponse"];
};
};
updateCountry: {
@ -6585,9 +6866,23 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody: components["requestBodies"]["CountryUpdateRequest"];
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CountryResponse"];
};
};
renameCountryCode: {
parameters: {
query?: never;
header?: never;
path: {
country_id: number;
};
cookie?: never;
};
requestBody: components["requestBodies"]["CountryRenameCodeRequest"];
responses: {
201: components["responses"]["CountryCodeRenameJobResponse"];
};
};
disableCountry: {
@ -6601,7 +6896,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CountryResponse"];
};
};
enableCountry: {
@ -6615,7 +6910,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["CountryResponse"];
};
};
listEmojiPacks: {
@ -9658,6 +9953,51 @@ export interface operations {
};
};
};
listJobs: {
parameters: {
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
keyword?: components["parameters"]["Keyword"];
status?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["AdminJobPageResponse"];
};
};
getJob: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["Id"];
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["AdminJobResponse"];
};
};
cancelJob: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["Id"];
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listTeams: {
parameters: {
query?: {

View File

@ -830,6 +830,7 @@ export interface HostProfileDto {
export interface CoinSellerDto {
avatar?: string;
canManageSubCoinSellers?: boolean;
contact?: string;
createdAtMs?: number;
createdByUserId?: number;
@ -1609,11 +1610,13 @@ export interface BDStatusPayload extends HostCommandPayload {
}
export interface CreateCoinSellerPayload extends HostCommandPayload {
canManageSubCoinSellers?: boolean;
contact?: string;
targetUserId: string;
}
export interface CoinSellerStatusPayload extends BDStatusPayload {
canManageSubCoinSellers?: boolean;
contact?: string;
}
@ -1652,7 +1655,7 @@ export interface CountryDto {
}
export interface CountryPayload {
countryCode?: string;
countryCode: string;
countryDisplayName: string;
countryName: string;
enabled?: boolean;
@ -1665,6 +1668,34 @@ export interface CountryPayload {
export type CountryUpdatePayload = Omit<CountryPayload, "countryCode" | "enabled">;
export type CountryRenameCodePayload = Omit<CountryPayload, "enabled">;
export interface CountryCodeRenameJobDto {
countryId: number;
jobId: number;
newCountryCode: string;
oldCountryCode: string;
status: string;
}
export interface AdminJobDto {
artifactPath?: string;
attempt?: number;
createdAtMs?: number;
createdBy?: number;
createdByName?: string;
error?: string;
finishedAtMs?: number | null;
id: number;
maxAttempts?: number;
payloadJson?: string;
resultJson?: string;
startedAtMs?: number | null;
status: string;
type: string;
updatedAtMs?: number;
}
export interface RegionDto {
countries?: string[];
createdAtMs?: number;