完善账号冻结与VIP免审核
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
-- 046_worker_freeze_reason.sql —— 记录账号冻结原因,避免被普通审核备注覆盖。
|
||||||
|
|
||||||
|
ALTER TABLE worker_users
|
||||||
|
ADD COLUMN IF NOT EXISTS frozen_reason TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE worker_users
|
||||||
|
ADD COLUMN IF NOT EXISTS frozen_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN worker_users.frozen_reason IS '最近一次冻结账号的原因或备注';
|
||||||
|
COMMENT ON COLUMN worker_users.frozen_at IS '最近一次冻结账号的时间';
|
||||||
@@ -19,6 +19,8 @@ export type WorkerUserRow = {
|
|||||||
status: string
|
status: string
|
||||||
worker_type: string
|
worker_type: string
|
||||||
review_note: string
|
review_note: string
|
||||||
|
frozen_reason: string
|
||||||
|
frozen_at: string | null
|
||||||
session_version: number
|
session_version: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|||||||
@@ -376,6 +376,8 @@ export async function updateWorkerUser(
|
|||||||
| 'status'
|
| 'status'
|
||||||
| 'worker_type'
|
| 'worker_type'
|
||||||
| 'review_note'
|
| 'review_note'
|
||||||
|
| 'frozen_reason'
|
||||||
|
| 'frozen_at'
|
||||||
| 'level_id'
|
| 'level_id'
|
||||||
| 'session_version'
|
| 'session_version'
|
||||||
| 'updated_at'
|
| 'updated_at'
|
||||||
@@ -394,16 +396,20 @@ export async function updateWorkerUser(
|
|||||||
status = $1,
|
status = $1,
|
||||||
worker_type = $2,
|
worker_type = $2,
|
||||||
review_note = $3,
|
review_note = $3,
|
||||||
level_id = $4,
|
frozen_reason = $4,
|
||||||
session_version = $5,
|
frozen_at = $5,
|
||||||
reviewed_at = $6,
|
level_id = $6,
|
||||||
updated_at = $7
|
session_version = $7,
|
||||||
WHERE id = $8
|
reviewed_at = $8,
|
||||||
|
updated_at = $9
|
||||||
|
WHERE id = $10
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
next.status,
|
next.status,
|
||||||
next.worker_type || 'external',
|
next.worker_type || 'external',
|
||||||
next.review_note || '',
|
next.review_note || '',
|
||||||
|
next.frozen_reason || '',
|
||||||
|
next.frozen_at || null,
|
||||||
next.level_id || null,
|
next.level_id || null,
|
||||||
Number(next.session_version || 1),
|
Number(next.session_version || 1),
|
||||||
next.reviewed_at || null,
|
next.reviewed_at || null,
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ import {
|
|||||||
normalizeSubmittedFields,
|
normalizeSubmittedFields,
|
||||||
normalizeUploadedFiles,
|
normalizeUploadedFiles,
|
||||||
normalizeWorkerType,
|
normalizeWorkerType,
|
||||||
|
normalizeWorkerFreezeReason,
|
||||||
normalizeWithdrawChannel,
|
normalizeWithdrawChannel,
|
||||||
resolveFreezeDepositAmount,
|
resolveFreezeDepositAmount,
|
||||||
mapWorkOrderEvents,
|
mapWorkOrderEvents,
|
||||||
@@ -1025,6 +1026,10 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
|
|||||||
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
|
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
|
||||||
const worker = await getRequiredWorker(workerId)
|
const worker = await getRequiredWorker(workerId)
|
||||||
const status = normalizeReviewStatus(payload.status)
|
const status = normalizeReviewStatus(payload.status)
|
||||||
|
const freezeReason =
|
||||||
|
status === 'rejected'
|
||||||
|
? normalizeWorkerFreezeReason(payload.freezeReason ?? payload.freeze_reason)
|
||||||
|
: worker.frozen_reason || ''
|
||||||
const workerType = normalizeWorkerType(payload.workerType ?? payload.worker_type)
|
const workerType = normalizeWorkerType(payload.workerType ?? payload.worker_type)
|
||||||
const requestedLevelId = normalizeOptionalId(payload.levelId)
|
const requestedLevelId = normalizeOptionalId(payload.levelId)
|
||||||
let levelId = worker.level_id || null
|
let levelId = worker.level_id || null
|
||||||
@@ -1046,7 +1051,12 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
|
|||||||
status,
|
status,
|
||||||
worker_type: workerType,
|
worker_type: workerType,
|
||||||
level_id: levelId,
|
level_id: levelId,
|
||||||
review_note: String(payload.reviewNote || payload.note || '').trim(),
|
review_note:
|
||||||
|
status === 'rejected'
|
||||||
|
? freezeReason
|
||||||
|
: String((payload.reviewNote ?? payload.note ?? worker.review_note) || '').trim(),
|
||||||
|
frozen_reason: freezeReason,
|
||||||
|
frozen_at: status === 'rejected' ? now : worker.frozen_at,
|
||||||
session_version: normalizeSessionVersion(worker.session_version) + 1,
|
session_version: normalizeSessionVersion(worker.session_version) + 1,
|
||||||
reviewed_at: now,
|
reviewed_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
|||||||
@@ -110,6 +110,17 @@ export function resolveWorkerPermissions(worker: WorkerUserRow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整单接单人与拼单参与者使用不同验收路径。
|
||||||
|
* 母工单保留拼单配置时,后台仍可在无人参与前整单指派,不能因此失去 VIP 免审核资格。
|
||||||
|
*/
|
||||||
|
export function canWorkerAutoAcceptWholeOrder(input: {
|
||||||
|
worker: WorkerUserRow
|
||||||
|
hasPersonalSharingShare: boolean
|
||||||
|
}): boolean {
|
||||||
|
return !input.hasPersonalSharingShare && resolveWorkerPermissions(input.worker).autoAcceptWithoutEvidence
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveVisibleDelaySeconds(levelKey: string, configured?: unknown): number {
|
export function resolveVisibleDelaySeconds(levelKey: string, configured?: unknown): number {
|
||||||
const configuredValue = normalizeInteger(configured, -1)
|
const configuredValue = normalizeInteger(configured, -1)
|
||||||
if (configuredValue >= 0) return configuredValue
|
if (configuredValue >= 0) return configuredValue
|
||||||
@@ -228,6 +239,8 @@ export function mapWorkerUser(worker: WorkerUserRow) {
|
|||||||
status: worker.status,
|
status: worker.status,
|
||||||
workerType: normalizeWorkerType(worker.worker_type),
|
workerType: normalizeWorkerType(worker.worker_type),
|
||||||
reviewNote: worker.review_note || '',
|
reviewNote: worker.review_note || '',
|
||||||
|
freezeReason: worker.frozen_reason || '',
|
||||||
|
frozenAt: worker.frozen_at,
|
||||||
inviteCode: worker.invite_code || '',
|
inviteCode: worker.invite_code || '',
|
||||||
inviter: worker.inviter_id
|
inviter: worker.inviter_id
|
||||||
? {
|
? {
|
||||||
@@ -1394,6 +1407,24 @@ export function normalizeReviewStatus(value: unknown) {
|
|||||||
return 'pending_review'
|
return 'pending_review'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 冻结原因必须可追溯,限制长度避免异常大载荷写入账号资料。 */
|
||||||
|
export function normalizeWorkerFreezeReason(value: unknown): string {
|
||||||
|
const reason = String(value || '').trim()
|
||||||
|
if (!reason) {
|
||||||
|
throw createHttpError('请填写冻结原因或备注', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_freeze_reason_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (reason.length > 500) {
|
||||||
|
throw createHttpError('冻结原因不能超过 500 个字符', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'worker_freeze_reason_too_long',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction {
|
export function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction {
|
||||||
const action = String(value || '').trim()
|
const action = String(value || '').trim()
|
||||||
if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) {
|
if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) {
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
|||||||
import {
|
import {
|
||||||
assertWithdrawChannelAllowedForAmount,
|
assertWithdrawChannelAllowedForAmount,
|
||||||
assertWorkerLoginAllowed,
|
assertWorkerLoginAllowed,
|
||||||
|
canWorkerAutoAcceptWholeOrder,
|
||||||
isWorkOrderSharingFilled,
|
isWorkOrderSharingFilled,
|
||||||
isWorkerPasswordStrong,
|
isWorkerPasswordStrong,
|
||||||
normalizeWithdrawChannel,
|
normalizeWithdrawChannel,
|
||||||
normalizeRequirementFields,
|
normalizeRequirementFields,
|
||||||
|
normalizeWorkerFreezeReason,
|
||||||
mapWorkOrderForWorker,
|
mapWorkOrderForWorker,
|
||||||
mapWorkOrderAdmin,
|
mapWorkOrderAdmin,
|
||||||
mapWorkOrderSharingWorkers,
|
mapWorkOrderSharingWorkers,
|
||||||
@@ -451,6 +453,16 @@ test('assertWorkerLoginAllowed blocks rejected (frozen) workers with worker_reje
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('冻结账号必须填写原因,并限制原因长度', () => {
|
||||||
|
assert.throws(() => normalizeWorkerFreezeReason(''), {
|
||||||
|
message: '请填写冻结原因或备注',
|
||||||
|
})
|
||||||
|
assert.equal(normalizeWorkerFreezeReason('多次超时未完成订单'), '多次超时未完成订单')
|
||||||
|
assert.throws(() => normalizeWorkerFreezeReason('a'.repeat(501)), {
|
||||||
|
message: '冻结原因不能超过 500 个字符',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('assertWorkerLoginAllowed blocks pending review workers with worker_pending_review', () => {
|
test('assertWorkerLoginAllowed blocks pending review workers with worker_pending_review', () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'pending_review' })),
|
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'pending_review' })),
|
||||||
@@ -511,6 +523,22 @@ test('等级免审核后补图权限默认关闭,仅显式开启时生效', ()
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('后台整单指派给 VIP5 时不因母工单保留拼单配置而失去免审核资格', () => {
|
||||||
|
const vip5Worker = buildWorkerUserRow({
|
||||||
|
level_key: 'vip5',
|
||||||
|
level_permission_json: { autoAcceptWithoutEvidence: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
canWorkerAutoAcceptWholeOrder({ worker: vip5Worker, hasPersonalSharingShare: false }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
canWorkerAutoAcceptWholeOrder({ worker: vip5Worker, hasPersonalSharingShare: true }),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('normalizeRequirementFields default template keeps gameId/gameNickname as text inputs', () => {
|
test('normalizeRequirementFields default template keeps gameId/gameNickname as text inputs', () => {
|
||||||
const fields = normalizeRequirementFields([])
|
const fields = normalizeRequirementFields([])
|
||||||
const gameId = fields.find((field) => field.key === 'gameId')
|
const gameId = fields.find((field) => field.key === 'gameId')
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ import {
|
|||||||
resolveFreezeDepositAmount,
|
resolveFreezeDepositAmount,
|
||||||
resolveRequirementFields,
|
resolveRequirementFields,
|
||||||
resolveWorkerPermissions,
|
resolveWorkerPermissions,
|
||||||
|
canWorkerAutoAcceptWholeOrder,
|
||||||
safeCompare,
|
safeCompare,
|
||||||
signWorkerPayload,
|
signWorkerPayload,
|
||||||
throwGrabWorkOrderFailure,
|
throwGrabWorkOrderFailure,
|
||||||
@@ -2017,8 +2018,10 @@ export async function submitWorkerOrderAcceptance(
|
|||||||
if (sharingShare) {
|
if (sharingShare) {
|
||||||
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
|
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
|
||||||
}
|
}
|
||||||
const canAutoAcceptWithoutEvidence =
|
const canAutoAcceptWithoutEvidence = canWorkerAutoAcceptWholeOrder({
|
||||||
!workOrder.sharing_enabled && resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
|
worker,
|
||||||
|
hasPersonalSharingShare: false,
|
||||||
|
})
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
if (
|
if (
|
||||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ type WithdrawalAccountFormValues = {
|
|||||||
wechatQrCodeImages?: UploadedFile[]
|
wechatQrCodeImages?: UploadedFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FreezeWorkerFormValues = {
|
||||||
|
freezeReason: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkersPanel() {
|
export default function WorkersPanel() {
|
||||||
const { message, modal } = App.useApp()
|
const { message, modal } = App.useApp()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -55,6 +59,7 @@ export default function WorkersPanel() {
|
|||||||
const [levelWorker, setLevelWorker] = useState<WorkerUser | null>(null)
|
const [levelWorker, setLevelWorker] = useState<WorkerUser | null>(null)
|
||||||
const [withdrawalAccountWorker, setWithdrawalAccountWorker] = useState<WorkerUser | null>(null)
|
const [withdrawalAccountWorker, setWithdrawalAccountWorker] = useState<WorkerUser | null>(null)
|
||||||
const [resetPasswordWorker, setResetPasswordWorker] = useState<WorkerUser | null>(null)
|
const [resetPasswordWorker, setResetPasswordWorker] = useState<WorkerUser | null>(null)
|
||||||
|
const [freezeWorker, setFreezeWorker] = useState<WorkerUser | null>(null)
|
||||||
const [withdrawalAccounts, setWithdrawalAccounts] = useState<AdminWorkerWithdrawalAccount[]>([])
|
const [withdrawalAccounts, setWithdrawalAccounts] = useState<AdminWorkerWithdrawalAccount[]>([])
|
||||||
const [loadingWithdrawalAccounts, setLoadingWithdrawalAccounts] = useState(false)
|
const [loadingWithdrawalAccounts, setLoadingWithdrawalAccounts] = useState(false)
|
||||||
const [savingWithdrawalAccount, setSavingWithdrawalAccount] = useState(false)
|
const [savingWithdrawalAccount, setSavingWithdrawalAccount] = useState(false)
|
||||||
@@ -62,6 +67,7 @@ export default function WorkersPanel() {
|
|||||||
const [levelForm] = Form.useForm<{ levelId?: number }>()
|
const [levelForm] = Form.useForm<{ levelId?: number }>()
|
||||||
const [withdrawalAccountForm] = Form.useForm<WithdrawalAccountFormValues>()
|
const [withdrawalAccountForm] = Form.useForm<WithdrawalAccountFormValues>()
|
||||||
const [resetPasswordForm] = Form.useForm<{ password: string }>()
|
const [resetPasswordForm] = Form.useForm<{ password: string }>()
|
||||||
|
const [freezeForm] = Form.useForm<FreezeWorkerFormValues>()
|
||||||
const withdrawalAccountChannel = Form.useWatch('accountChannel', withdrawalAccountForm)
|
const withdrawalAccountChannel = Form.useWatch('accountChannel', withdrawalAccountForm)
|
||||||
|
|
||||||
const workersQuery = useQuery({
|
const workersQuery = useQuery({
|
||||||
@@ -108,6 +114,22 @@ export default function WorkersPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitFreezeWorker(values: FreezeWorkerFormValues) {
|
||||||
|
if (!freezeWorker) return
|
||||||
|
try {
|
||||||
|
await reviewAdminWorkerUser(freezeWorker.workerId, {
|
||||||
|
status: 'rejected',
|
||||||
|
freezeReason: values.freezeReason.trim(),
|
||||||
|
})
|
||||||
|
message.success('打手账号已冻结,原登录设备已退出')
|
||||||
|
setFreezeWorker(null)
|
||||||
|
freezeForm.resetFields()
|
||||||
|
await refreshWorkers()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '冻结账号失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleWorkerType(worker: WorkerUser, workerType: 'internal' | 'external') {
|
function toggleWorkerType(worker: WorkerUser, workerType: 'internal' | 'external') {
|
||||||
const isInternal = workerType === 'internal'
|
const isInternal = workerType === 'internal'
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
@@ -297,6 +319,18 @@ export default function WorkersPanel() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '冻结原因',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.status === 'rejected' && row.freezeReason ? (
|
||||||
|
<Typography.Text ellipsis={{ tooltip: row.freezeReason }}>
|
||||||
|
{row.freezeReason}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 340,
|
width: 340,
|
||||||
@@ -306,7 +340,9 @@ export default function WorkersPanel() {
|
|||||||
<Button onClick={() => review(row, 'active')}>启用</Button>
|
<Button onClick={() => review(row, 'active')}>启用</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.status !== 'rejected' ? (
|
{row.status !== 'rejected' ? (
|
||||||
<Button onClick={() => review(row, 'rejected')}>拒绝</Button>
|
<Button danger onClick={() => setFreezeWorker(row)}>
|
||||||
|
冻结
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.workerType !== 'internal' ? (
|
{row.workerType !== 'internal' ? (
|
||||||
<Button onClick={() => toggleWorkerType(row, 'internal')}>设为内部</Button>
|
<Button onClick={() => toggleWorkerType(row, 'internal')}>设为内部</Button>
|
||||||
@@ -406,6 +442,40 @@ export default function WorkersPanel() {
|
|||||||
scroll={{ x: 1000 }}
|
scroll={{ x: 1000 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="冻结打手账号"
|
||||||
|
open={Boolean(freezeWorker)}
|
||||||
|
onCancel={() => {
|
||||||
|
setFreezeWorker(null)
|
||||||
|
freezeForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => freezeForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={freezeForm} layout="vertical" onFinish={submitFreezeWorker}>
|
||||||
|
<Form.Item label="打手">
|
||||||
|
<Typography.Text>
|
||||||
|
{freezeWorker?.displayName || freezeWorker?.username || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="冻结原因"
|
||||||
|
name="freezeReason"
|
||||||
|
rules={[
|
||||||
|
{ required: true, whitespace: true, message: '请填写冻结原因或备注' },
|
||||||
|
{ max: 500, message: '冻结原因不能超过 500 个字符' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="例如:多次超时未完成订单,经核实后冻结账号"
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="重置打手密码"
|
title="重置打手密码"
|
||||||
open={Boolean(resetPasswordWorker)}
|
open={Boolean(resetPasswordWorker)}
|
||||||
|
|||||||
@@ -277,7 +277,13 @@ export function reprocessAdminKuaishouWorkOrderMatches(limit = 100) {
|
|||||||
|
|
||||||
export function reviewAdminWorkerUser(
|
export function reviewAdminWorkerUser(
|
||||||
workerId: number,
|
workerId: number,
|
||||||
payload: { status: string; reviewNote?: string; levelId?: number; workerType?: string },
|
payload: {
|
||||||
|
status: string
|
||||||
|
reviewNote?: string
|
||||||
|
freezeReason?: string
|
||||||
|
levelId?: number
|
||||||
|
workerType?: string
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return apiPost<{ worker: WorkerUser }>(
|
return apiPost<{ worker: WorkerUser }>(
|
||||||
`/api/v1/admin/worker-platform/workers/${workerId}/review`,
|
`/api/v1/admin/worker-platform/workers/${workerId}/review`,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type WorkerUser = {
|
|||||||
status: string
|
status: string
|
||||||
workerType: string
|
workerType: string
|
||||||
reviewNote: string
|
reviewNote: string
|
||||||
|
freezeReason: string
|
||||||
|
frozenAt: string | null
|
||||||
inviteCode: string
|
inviteCode: string
|
||||||
inviter: null | {
|
inviter: null | {
|
||||||
workerId: number
|
workerId: number
|
||||||
|
|||||||
Reference in New Issue
Block a user