+ {row.unfinishedOrders.length > 0 && (
+ <>
+
未完成订单({row.unfinishedOrders.length})
+
+ >
+ )}
+ {row.redeemRecords.length > 0 && (
+ <>
+
核销记录({row.redeemRecords.length})
+
`¥${v}` },
+ {
+ title: '打款状态',
+ dataIndex: 'payoutStatus',
+ width: 100,
+ render: (v) => (v === 'PENDING' ? 待打款 : v === 'PAID' ? 已打款 : '—'),
+ },
+ { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
+ ]}
+ />
+ >
+ )}
+
+ ),
+ }}
+ columns={[
+ { title: '用户编号', dataIndex: 'userNo', width: 120 },
+ { title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
+ { title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
+ {
+ title: '风险',
+ width: 200,
+ render: (_, row) => (row.hasRisk ? (
+
+ 需关注
+ {row.unfinishedOrders.length > 0 && (
+ 未完成订单 {row.unfinishedOrders.length}
+ )}
+ {row.redeemRecords.length > 0 && (
+ 核销 {row.redeemRecords.length}
+ )}
+
+ ) : (
+ 无关联风险
+ )),
+ },
+ ]}
+ />
+ >
+ )}
+ {!batchPreviewLoading && batchPreview && batchDeleteStep === 2 && (
+ <>
+
+ 以下 {batchPreview.items.filter((i) => i.hasRisk).length} 名用户存在未完成订单或核销记录。
+ 确认后将永久删除这些订单、核销记录、权益券及门店打款关联数据,且不可恢复。
+ >
+ )}
+ />
+ i.hasRisk)}
+ columns={[
+ { title: '用户编号', dataIndex: 'userNo', width: 120 },
+ { title: '未完成订单', width: 110, render: (_, row) => row.unfinishedOrders.length },
+ { title: '核销记录', width: 90, render: (_, row) => row.redeemRecords.length },
+ ]}
+ />
+ setBatchRiskAck(e.target.checked)}>
+ 我确认删除上述用户的未完成订单、核销记录及相关业务数据
+
+ >
+ )}
+
);
}
diff --git a/deploy/auto-release.sh b/deploy/auto-release.sh
index 7510abe..00544a6 100644
--- a/deploy/auto-release.sh
+++ b/deploy/auto-release.sh
@@ -13,7 +13,7 @@ LOG_FILE="${DEPLOY_LOG_FILE:-/var/log/dukang/deploy.log}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
- source "$ENV_FILE"
+ source <(sed 's/\r$//' "$ENV_FILE")
fi
mkdir -p "$(dirname "$LOG_FILE")"
@@ -49,7 +49,23 @@ if [[ "$LOCAL_SHA" == "$REMOTE_SHA" ]]; then
fi
log "PULL $LOCAL_SHA -> $REMOTE_SHA"
-git pull --ff-only "$GIT_REMOTE" "$GIT_BRANCH"
+
+# 保留生产密钥,避免 reset 覆盖
+ENV_BACKUP="$(mktemp)"
+API_ENV_BACKUP="$(mktemp)"
+API_ENV_PROD_BACKUP="$(mktemp)"
+cp "$ENV_FILE" "$ENV_BACKUP" 2>/dev/null || true
+cp "$APP_ROOT/server/dukang-api/.env" "$API_ENV_BACKUP" 2>/dev/null || true
+cp "$APP_ROOT/server/dukang-api/.env.production" "$API_ENV_PROD_BACKUP" 2>/dev/null || true
+
+git checkout "$GIT_BRANCH"
+git reset --hard "$GIT_REMOTE/$GIT_BRANCH"
+
+cp "$ENV_BACKUP" "$ENV_FILE" 2>/dev/null || true
+cp "$API_ENV_BACKUP" "$APP_ROOT/server/dukang-api/.env" 2>/dev/null || true
+cp "$API_ENV_PROD_BACKUP" "$APP_ROOT/server/dukang-api/.env.production" 2>/dev/null || true
+rm -f "$ENV_BACKUP" "$API_ENV_BACKUP" "$API_ENV_PROD_BACKUP"
+sed -i 's/\r$//' "$ENV_FILE" 2>/dev/null || true
# 修复 Windows 换行
find "$APP_ROOT/deploy" -maxdepth 1 -name '*.sh' -exec sed -i 's/\r$//' {} + 2>/dev/null || true
diff --git a/deploy/remote-release.sh b/deploy/remote-release.sh
index f11de2a..6d6c2b0 100644
--- a/deploy/remote-release.sh
+++ b/deploy/remote-release.sh
@@ -45,15 +45,8 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=2048}"
pnpm approve-builds --all 2>/dev/null || true
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
-if [[ "$SKIP_BUILD" == false ]]; then
- echo "==> 3. 构建"
- pnpm build
-else
- echo "==> 3. 跳过构建"
-fi
-
if [[ "$SKIP_DB" == false ]]; then
- echo "==> 4. 数据库 schema 同步"
+ echo "==> 3. 数据库 schema 同步(构建前)"
cd "$APP_ROOT/server/dukang-api"
pnpm prisma:generate
if [[ "$DB_PUSH_ACCEPT_DATA_LOSS" == true ]]; then
@@ -65,8 +58,16 @@ if [[ "$SKIP_DB" == false ]]; then
echo "==> 执行 seed"
pnpm prisma:seed
fi
+ cd "$APP_ROOT"
else
- echo "==> 4. 跳过数据库"
+ echo "==> 3. 跳过数据库"
+fi
+
+if [[ "$SKIP_BUILD" == false ]]; then
+ echo "==> 4. 构建"
+ pnpm build
+else
+ echo "==> 4. 跳过构建"
fi
echo "==> 5. 重启 PM2"
diff --git a/deploy/setup-webhook.sh b/deploy/setup-webhook.sh
index f62065b..16d2ad9 100644
--- a/deploy/setup-webhook.sh
+++ b/deploy/setup-webhook.sh
@@ -22,6 +22,7 @@ if [[ ! -f "$ENV_FILE" ]]; then
else
echo " 保留已有 $ENV_FILE"
fi
+sed -i 's/\r$//' "$ENV_FILE" 2>/dev/null || true
echo "==> 3. Nginx webhook 路由"
HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index 86061b8..98dfcc9 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -67,3 +67,10 @@ XIAOFEIXIA_MCH_ID=
XIAOFEIXIA_API_KEY=
XIAOFEIXIA_SIGN_TYPE=MD5
# XIAOFEIXIA_APP_ID=
+# HQ 订单发货默认寄件信息
+SHIP_FROM_NAME=杜康仓库
+SHIP_FROM_MOBILE=13800000000
+SHIP_FROM_ADDRESS=河南省郑州市金水区
+SHIP_FROM_ADDRESS_DETAIL=杜康酒业仓
+SHIP_FROM_LNG=113.665
+SHIP_FROM_LAT=34.757
diff --git a/server/dukang-api/src/integrations/courier/courier.config.ts b/server/dukang-api/src/integrations/courier/courier.config.ts
index eab15d3..9554bea 100644
--- a/server/dukang-api/src/integrations/courier/courier.config.ts
+++ b/server/dukang-api/src/integrations/courier/courier.config.ts
@@ -29,7 +29,7 @@ export class CourierConfigService {
provider,
xiaofeixia: {
apiUrl:
- this.config.get('XIAOFEIXIA_API_URL') ??
+ this.config.get('XIAOFEIXIA_API_URL')?.trim() ||
'https://beta.51xiaoju.cn/app/api/interface.do',
appId: this.config.get('XIAOFEIXIA_APP_ID') || undefined,
mchId: this.config.get('XIAOFEIXIA_MCH_ID') ?? '',
diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts
index d3c7417..c633050 100644
--- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts
+++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts
@@ -66,7 +66,22 @@ export class XiaofeixiaClient {
);
}
- const payload = (await response.json()) as XiaofeixiaApiResponse;
+ const rawText = await response.text();
+ let payload: XiaofeixiaApiResponse;
+ try {
+ payload = rawText ? (JSON.parse(rawText) as XiaofeixiaApiResponse) : (null as unknown as XiaofeixiaApiResponse);
+ } catch {
+ throw new CourierApiError(
+ `小飞侠响应非 JSON(HTTP ${response.status}): ${rawText.slice(0, 200) || '(空)'}`,
+ '200000',
+ 'XIAOFEIXIA',
+ rawText,
+ );
+ }
+
+ if (!payload) {
+ throw new CourierApiError('小飞侠返回空响应', '200000', 'XIAOFEIXIA');
+ }
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
throw new CourierApiError(
diff --git a/server/dukang-api/src/modules/ops/admin-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-orders.controller.ts
index 7bab823..f6f74bc 100644
--- a/server/dukang-api/src/modules/ops/admin-orders.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-orders.controller.ts
@@ -1,7 +1,8 @@
-import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
+import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminOrdersService } from './admin-orders.service';
-import { UpdateOrderStatusDto } from './dto/admin-mutate.dto';
+import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Controller('admin/orders')
@@ -14,11 +15,28 @@ export class AdminOrdersController {
return this.ordersService.list(query);
}
+ @Post('batch-delete')
+ @UseGuards(SuperAdminGuard)
+ batchDelete(@Body() dto: BatchDeleteOrdersDto) {
+ return this.ordersService.batchDeleteOrders(dto.ids.map((id) => BigInt(id)));
+ }
+
+ @Get('ship-defaults')
+ shipDefaults() {
+ return this.ordersService.getShipDefaults();
+ }
+
@Get(':id')
detail(@Param('id') id: string) {
return this.ordersService.detail(BigInt(id));
}
+ /** HQ 发货:调用小飞侠创建运单并更新配送信息 */
+ @Post(':id/ship')
+ ship(@Param('id') id: string, @Body() dto: AdminShipOrderDto) {
+ return this.ordersService.shipOrder(BigInt(id), dto);
+ }
+
/** preV1 调试:直接改订单状态,不走业务校验 */
@Put(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts
index 7b974b8..8a1f0b9 100644
--- a/server/dukang-api/src/modules/ops/admin-orders.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts
@@ -1,17 +1,23 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { TradeService } from '../trade/trade.service';
+import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
+import type { AdminShipOrderDto } from './dto/admin-mutate.dto';
+import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly tradeService: TradeService,
+ private readonly xiaofeixiaService: AdminXiaofeixiaService,
+ private readonly config: ConfigService,
) {}
async list(query: AdminOrdersQueryDto) {
@@ -87,4 +93,156 @@ export class AdminOrdersService {
await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG');
return this.detail(id);
}
+
+ getShipDefaults() {
+ return {
+ provider: 'XFX',
+ providerLabel: '小飞侠',
+ fromName: this.config.get('SHIP_FROM_NAME') || '杜康仓库',
+ fromMobile: this.config.get('SHIP_FROM_MOBILE') || '13800000000',
+ fromAddress: this.config.get('SHIP_FROM_ADDRESS') || '河南省郑州市金水区',
+ fromAddressDetail: this.config.get('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓',
+ fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665),
+ fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757),
+ weight: 2,
+ payMode: '1',
+ };
+ }
+
+ async shipOrder(id: bigint, dto: AdminShipOrderDto) {
+ if (dto.provider !== 'XFX') {
+ throw new BadRequestException('暂仅支持小飞侠配送');
+ }
+
+ const order = await this.prisma.order.findUnique({
+ where: { id },
+ include: { delivery: true },
+ });
+ if (!order) throw new NotFoundException('订单不存在');
+
+ if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
+ throw new BadRequestException('当前订单状态不可发货');
+ }
+ if (order.delivery?.trackingNo) {
+ throw new BadRequestException('该订单已有运单号,请勿重复发货');
+ }
+
+ const defaults = this.getShipDefaults();
+ const shipmentDto: XiaofeixiaCreateShipmentDto = {
+ outNumber: order.orderNo,
+ fromName: dto.fromName || defaults.fromName,
+ fromMobile: dto.fromMobile || defaults.fromMobile,
+ fromAddress: dto.fromAddress || defaults.fromAddress,
+ fromAddressDetail: dto.fromAddressDetail || defaults.fromAddressDetail,
+ fromLng: dto.fromLng ?? defaults.fromLng,
+ fromLat: dto.fromLat ?? defaults.fromLat,
+ toName: order.receiverName,
+ toMobile: order.receiverPhone,
+ toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
+ toAddressDetail: order.receiverAddress,
+ goodsName: order.productName,
+ goodsNum: order.quantity,
+ weight: dto.weight ?? defaults.weight,
+ payMode: dto.payMode || defaults.payMode,
+ remark: dto.remark || `HQ发货 ${order.orderNo}`,
+ };
+
+ const result = await this.xiaofeixiaService.createShipment(shipmentDto);
+ if (!result.ok || !result.data) {
+ throw new BadRequestException(result.error || '小飞侠创建运单失败');
+ }
+
+ const { providerShipmentId, trackingNumber } = result.data;
+ const now = new Date();
+
+ await this.prisma.$transaction(async (tx) => {
+ if (order.delivery) {
+ await tx.orderDelivery.update({
+ where: { orderId: id },
+ data: {
+ provider: 'XFX',
+ trackingNo: trackingNumber,
+ providerOrderNo: String(providerShipmentId),
+ shippingAt: now,
+ },
+ });
+ } else {
+ await tx.orderDelivery.create({
+ data: {
+ orderId: id,
+ provider: 'XFX',
+ trackingNo: trackingNumber,
+ providerOrderNo: String(providerShipmentId),
+ shippingAt: now,
+ },
+ });
+ }
+ });
+
+ await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP');
+ return this.detail(id);
+ }
+
+ async batchDeleteOrders(ids: bigint[]) {
+ const uniqueIds = [...new Set(ids)];
+ if (!uniqueIds.length) {
+ return { ok: true, deleted: 0, message: '未选择订单' };
+ }
+
+ const orders = await this.prisma.order.findMany({
+ where: { id: { in: uniqueIds } },
+ select: { id: true, orderNo: true },
+ });
+ if (!orders.length) throw new NotFoundException('订单不存在');
+
+ const orderIds = orders.map((o) => o.id);
+
+ await this.prisma.$transaction(async (tx) => {
+ await this.deleteOrdersInTx(tx, orderIds);
+ });
+
+ return {
+ ok: true,
+ deleted: orderIds.length,
+ orderNos: orders.map((o) => o.orderNo),
+ message: '订单及关联业务数据已删除,状态流转等业务日志已保留',
+ };
+ }
+
+ private async deleteOrdersInTx(tx: Prisma.TransactionClient, orderIds: bigint[]) {
+ if (!orderIds.length) return;
+
+ const couponIds = (
+ await tx.benefitCoupon.findMany({
+ where: { orderId: { in: orderIds } },
+ select: { id: true },
+ })
+ ).map((c) => c.id);
+
+ if (couponIds.length) {
+ const redeemIds = (
+ await tx.redeemRecord.findMany({
+ where: { couponId: { in: couponIds } },
+ select: { id: true },
+ })
+ ).map((r) => r.id);
+
+ if (redeemIds.length) {
+ await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
+ await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
+ await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
+ }
+
+ await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } });
+ }
+
+ await tx.order.updateMany({
+ where: { originOrderId: { in: orderIds } },
+ data: { originOrderId: null },
+ });
+ await tx.commonTicket.deleteMany({
+ where: { refType: 'ORDER', refId: { in: orderIds } },
+ });
+ await tx.order.deleteMany({ where: { id: { in: orderIds } } });
+ }
}
diff --git a/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts b/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts
index d648fa6..7005c33 100644
--- a/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts
@@ -25,7 +25,7 @@ type TemplateRow = {
updatedAt: Date;
};
-const MAX_TEMPLATE_DETAIL_IMAGES = 20;
+const MAX_TEMPLATE_DETAIL_IMAGES = 30;
@Injectable()
export class AdminProductDetailTemplatesService {
diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts
new file mode 100644
index 0000000..b560da5
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts
@@ -0,0 +1,31 @@
+import { Body, Controller, Post, UseGuards } from '@nestjs/common';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { AdminRedeemDebugService } from './admin-redeem-debug.service';
+import type {
+ AdminRedeemDebugCreateTokenDto,
+ AdminRedeemDebugStoreTokenDto,
+} from './dto/admin-mutate.dto';
+
+@Controller('admin/redeem/debug')
+@UseGuards(HqAuthGuard)
+export class AdminRedeemDebugController {
+ constructor(private readonly service: AdminRedeemDebugService) {}
+
+ /** preV1 调试:为用户生成核销码 */
+ @Post('create-token')
+ createToken(@Body() dto: AdminRedeemDebugCreateTokenDto) {
+ return this.service.createToken(dto);
+ }
+
+ /** preV1 调试:门店侧预览核销 */
+ @Post('preview')
+ preview(@Body() dto: AdminRedeemDebugStoreTokenDto) {
+ return this.service.preview(dto);
+ }
+
+ /** preV1 调试:门店侧确认核销 */
+ @Post('confirm')
+ confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) {
+ return this.service.confirm(dto);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts
new file mode 100644
index 0000000..a99478c
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts
@@ -0,0 +1,45 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { RedeemService } from '../redeem/redeem.service';
+import type {
+ AdminRedeemDebugCreateTokenDto,
+ AdminRedeemDebugStoreTokenDto,
+} from './dto/admin-mutate.dto';
+
+@Injectable()
+export class AdminRedeemDebugService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly redeemService: RedeemService,
+ ) {}
+
+ private async resolveStoreAccountId(storeId: string): Promise {
+ const account = await this.prisma.storeAccount.findFirst({
+ where: { storeId: BigInt(storeId), status: 'ACTIVE' },
+ orderBy: { id: 'asc' },
+ select: { id: true, store: { select: { name: true } } },
+ });
+ if (!account) {
+ throw new NotFoundException('该门店无可用账户,请先创建门店账户');
+ }
+ return account.id;
+ }
+
+ async createToken(dto: AdminRedeemDebugCreateTokenDto) {
+ return this.redeemService.createToken(BigInt(dto.userId), {
+ amount: dto.amount,
+ couponId: dto.couponId,
+ storeId: dto.storeId,
+ });
+ }
+
+ async preview(dto: AdminRedeemDebugStoreTokenDto) {
+ const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
+ return this.redeemService.previewRedeem(storeAccountId, dto.token);
+ }
+
+ async confirm(dto: AdminRedeemDebugStoreTokenDto) {
+ const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
+ return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token });
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-users.controller.ts b/server/dukang-api/src/modules/ops/admin-users.controller.ts
index b34e4f9..54848ea 100644
--- a/server/dukang-api/src/modules/ops/admin-users.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-users.controller.ts
@@ -1,7 +1,9 @@
-import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { AdminUsersService } from './admin-users.service';
import { AdminUsersQueryDto } from './dto/admin-query.dto';
+import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto } from './dto/admin-mutate.dto';
@Controller('admin/users')
@UseGuards(HqAuthGuard)
@@ -13,8 +15,29 @@ export class AdminUsersController {
return this.usersService.list(query);
}
+ @Post('batch-delete/preview')
+ @UseGuards(SuperAdminGuard)
+ previewBatchDelete(@Body() dto: BatchDeleteUsersDto) {
+ return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id)));
+ }
+
+ @Post('batch-delete')
+ @UseGuards(SuperAdminGuard)
+ batchDelete(@Body() dto: BatchDeleteUsersConfirmDto) {
+ return this.usersService.batchDeleteUsers(
+ dto.ids.map((id) => BigInt(id)),
+ dto.confirmRisk,
+ );
+ }
+
@Get(':id')
detail(@Param('id') id: string) {
return this.usersService.detail(BigInt(id));
}
+
+ @Delete(':id')
+ @UseGuards(SuperAdminGuard)
+ remove(@Param('id') id: string) {
+ return this.usersService.deleteUser(BigInt(id));
+ }
}
diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts
index 24699f0..9637995 100644
--- a/server/dukang-api/src/modules/ops/admin-users.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-users.service.ts
@@ -1,9 +1,42 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminUsersQueryDto } from './dto/admin-query.dto';
+const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const;
+
+function mapAdminUserRow(u: {
+ id: bigint;
+ userNo: string;
+ deviceKey: string | null;
+ phone: string | null;
+ phoneVerifiedAt: Date | null;
+ mergedIntoUserId: bigint | null;
+ wxOpenId: string | null;
+ nickname: string | null;
+ status: number;
+ createdAt: Date;
+ updatedAt: Date;
+ _count: { orders: number };
+}) {
+ return {
+ id: u.id,
+ userNo: u.userNo,
+ deviceKey: u.deviceKey,
+ phone: u.phone,
+ phoneVerifiedAt: u.phoneVerifiedAt,
+ mergedIntoUserId: u.mergedIntoUserId,
+ wxOpenId: u.wxOpenId,
+ wechatVerified: !!u.wxOpenId,
+ nickname: u.nickname,
+ status: u.status,
+ createdAt: u.createdAt,
+ updatedAt: u.updatedAt,
+ orderCount: u._count.orders,
+ };
+}
+
@Injectable()
export class AdminUsersService {
constructor(private readonly prisma: PrismaService) {}
@@ -33,6 +66,7 @@ export class AdminUsersService {
phone: true,
phoneVerifiedAt: true,
mergedIntoUserId: true,
+ wxOpenId: true,
nickname: true,
status: true,
createdAt: true,
@@ -44,11 +78,7 @@ export class AdminUsersService {
]);
return serializeBigInt({
- items: items.map((u) => ({
- ...u,
- orderCount: u._count.orders,
- _count: undefined,
- })),
+ items: items.map((u) => mapAdminUserRow(u)),
total,
page,
pageSize,
@@ -63,12 +93,12 @@ export class AdminUsersService {
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
orders: {
orderBy: { createdAt: 'desc' },
- take: 5,
select: {
id: true,
orderNo: true,
status: true,
payAmount: true,
+ payStatus: true,
createdAt: true,
},
},
@@ -79,10 +109,167 @@ export class AdminUsersService {
return serializeBigInt({
...user,
+ wechatVerified: !!user.wxOpenId,
mergedFromCount: user._count.mergedFrom,
orderCount: user._count.orders,
addressCount: user._count.addresses,
_count: undefined,
});
}
+
+ async previewBatchDelete(ids: bigint[]) {
+ const uniqueIds = [...new Set(ids)];
+ if (!uniqueIds.length) {
+ return { items: [], hasRisk: false, total: 0 };
+ }
+
+ const users = await this.prisma.user.findMany({
+ where: { id: { in: uniqueIds } },
+ select: {
+ id: true,
+ userNo: true,
+ nickname: true,
+ phone: true,
+ orders: {
+ where: { status: { notIn: [...FINISHED_ORDER_STATUSES] } },
+ select: { id: true, orderNo: true, status: true, payAmount: true, createdAt: true },
+ orderBy: { createdAt: 'desc' },
+ },
+ redeemRecords: {
+ select: {
+ id: true,
+ redeemNo: true,
+ amount: true,
+ createdAt: true,
+ payout: { select: { status: true } },
+ },
+ orderBy: { createdAt: 'desc' },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ const foundIds = new Set(users.map((u) => u.id.toString()));
+ const missing = uniqueIds.filter((id) => !foundIds.has(id.toString()));
+ if (missing.length) {
+ throw new NotFoundException('部分用户不存在');
+ }
+
+ const items = users.map((u) => {
+ const unfinishedOrders = u.orders;
+ const redeemRecords = u.redeemRecords.map((r) => ({
+ id: r.id,
+ redeemNo: r.redeemNo,
+ amount: r.amount,
+ createdAt: r.createdAt,
+ payoutStatus: r.payout?.status ?? null,
+ }));
+ const hasRisk = unfinishedOrders.length > 0 || redeemRecords.length > 0;
+ return {
+ id: u.id,
+ userNo: u.userNo,
+ nickname: u.nickname,
+ phone: u.phone,
+ unfinishedOrders,
+ redeemRecords,
+ hasRisk,
+ };
+ });
+
+ return serializeBigInt({
+ items,
+ hasRisk: items.some((i) => i.hasRisk),
+ total: items.length,
+ });
+ }
+
+ async batchDeleteUsers(ids: bigint[], confirmRisk: boolean) {
+ const preview = await this.previewBatchDelete(ids);
+ if (preview.hasRisk && !confirmRisk) {
+ const riskyUsers = preview.items.filter((i) => i.hasRisk);
+ throw new BadRequestException({
+ message: '所选用户存在未完成订单或核销记录,需二次确认后删除',
+ code: 'USER_DELETE_RISK',
+ riskyUsers: riskyUsers.map((u) => ({
+ id: u.id,
+ userNo: u.userNo,
+ unfinishedOrderCount: u.unfinishedOrders.length,
+ redeemRecordCount: u.redeemRecords.length,
+ })),
+ });
+ }
+
+ const userIds = preview.items.map((i) => BigInt(String(i.id)));
+ await this.prisma.$transaction(async (tx) => {
+ for (const id of userIds) {
+ await this.deleteUserInTx(tx, id);
+ }
+ });
+
+ return {
+ ok: true,
+ deleted: userIds.length,
+ message: '用户及关联业务数据已删除,行为日志已保留',
+ };
+ }
+
+ async deleteUser(id: bigint) {
+ const user = await this.prisma.user.findUnique({ where: { id } });
+ if (!user) throw new NotFoundException('用户不存在');
+
+ await this.prisma.$transaction(async (tx) => {
+ await this.deleteUserInTx(tx, id);
+ });
+
+ return {
+ ok: true,
+ message: '用户及关联业务数据已删除,行为日志已保留',
+ };
+ }
+
+ private async deleteUserInTx(tx: Prisma.TransactionClient, id: bigint) {
+ const user = await tx.user.findUnique({ where: { id } });
+ if (!user) return;
+
+ await tx.user.updateMany({
+ where: { mergedIntoUserId: id },
+ data: { mergedIntoUserId: null },
+ });
+
+ const orderIds = (
+ await tx.order.findMany({ where: { userId: id }, select: { id: true } })
+ ).map((o) => o.id);
+
+ const redeemIds = (
+ await tx.redeemRecord.findMany({ where: { userId: id }, select: { id: true } })
+ ).map((r) => r.id);
+
+ if (redeemIds.length) {
+ await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
+ await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
+ await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
+ }
+
+ await tx.benefitCoupon.deleteMany({ where: { userId: id } });
+
+ if (orderIds.length) {
+ await tx.order.updateMany({
+ where: { originOrderId: { in: orderIds } },
+ data: { originOrderId: null },
+ });
+ await tx.commonTicket.deleteMany({
+ where: { refType: 'ORDER', refId: { in: orderIds } },
+ });
+ await tx.order.deleteMany({ where: { id: { in: orderIds } } });
+ }
+
+ if (user.avatarResourceId) {
+ await tx.commonResource.updateMany({
+ where: { id: user.avatarResourceId, ownerType: 'USER', ownerId: id },
+ data: { status: 'DELETED' },
+ });
+ }
+
+ await tx.user.delete({ where: { id } });
+ }
}
diff --git a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts
index 489cadf..4f9bef2 100644
--- a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts
@@ -145,7 +145,12 @@ export class AdminXiaofeixiaService {
raw: err.raw,
};
}
- throw err;
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ ok: false,
+ elapsedMs: Date.now() - startedAt,
+ error: message,
+ };
}
}
}
diff --git a/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts
index a5cd347..b11d956 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts
@@ -7,8 +7,10 @@ import {
IsString,
Min,
} from 'class-validator';
+import { Type } from 'class-transformer';
export class XiaofeixiaEstimateFreightDto {
+ @Type(() => Number)
@IsNumber()
@Min(0.01)
weight: number;
diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
index b7d58fd..d2dc4e2 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
@@ -1,4 +1,5 @@
-import { IsArray, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
+import { Type } from 'class-transformer';
+import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
export class UpdateStoreStatusDto {
@IsString()
@@ -307,6 +308,96 @@ export class UpdateOrderStatusDto {
status: string;
}
+export class BatchDeleteOrdersDto {
+ @IsArray()
+ @IsString({ each: true })
+ ids: string[];
+}
+
+export class BatchDeleteUsersDto {
+ @IsArray()
+ @IsString({ each: true })
+ ids: string[];
+}
+
+export class BatchDeleteUsersConfirmDto extends BatchDeleteUsersDto {
+ @IsBoolean()
+ confirmRisk: boolean;
+}
+
+/** HQ 订单发货(目前仅小飞侠 XFX) */
+export class AdminShipOrderDto {
+ @IsIn(['XFX'])
+ provider: string;
+
+ @IsOptional()
+ @IsString()
+ fromName?: string;
+
+ @IsOptional()
+ @IsString()
+ fromMobile?: string;
+
+ @IsOptional()
+ @IsString()
+ fromAddress?: string;
+
+ @IsOptional()
+ @IsString()
+ fromAddressDetail?: string;
+
+ @IsOptional()
+ @IsNumber()
+ fromLng?: number;
+
+ @IsOptional()
+ @IsNumber()
+ fromLat?: number;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsNumber()
+ @Min(0.01)
+ weight?: number;
+
+ @IsOptional()
+ @IsIn(['1', '2'])
+ payMode?: string;
+
+ @IsOptional()
+ @IsString()
+ remark?: string;
+}
+
+export class AdminRedeemDebugCreateTokenDto {
+ @IsString()
+ @IsNotEmpty()
+ userId: string;
+
+ @Type(() => Number)
+ @IsNumber()
+ @Min(0.01)
+ amount: number;
+
+ @IsOptional()
+ @IsString()
+ couponId?: string;
+
+ @IsOptional()
+ @IsString()
+ storeId?: string;
+}
+
+export class AdminRedeemDebugStoreTokenDto {
+ @IsString()
+ @IsNotEmpty()
+ storeId: string;
+
+ @IsString()
+ @IsNotEmpty()
+ token: string;
+}
+
export class UpdateDeliveryDto {
@IsOptional()
@IsString()
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index e89392b..c32711f 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -33,9 +33,12 @@ import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
+import { RedeemModule } from '../redeem/redeem.module';
+import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
+import { AdminRedeemDebugService } from './admin-redeem-debug.service';
@Module({
- imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
+ imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -56,6 +59,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
AdminTicketsController,
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
+ AdminRedeemDebugController,
],
providers: [
AdminDashboardService,
@@ -73,6 +77,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
AdminTicketsService,
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,
+ AdminRedeemDebugService,
SuperAdminGuard,
],
})