feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { parseMiniHomeBanners } from '@dukang/shared-types';
|
||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||
|
||||
@Controller('common')
|
||||
export class ClientConfigController {
|
||||
constructor(private readonly systemConfig: SystemConfigService) {}
|
||||
|
||||
@Get('client-config')
|
||||
clientConfig() {
|
||||
const cfg = this.systemConfig.getAppConfig();
|
||||
const env = this.systemConfig.getMergedEnv();
|
||||
const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim();
|
||||
return {
|
||||
mockPay: cfg.mockPay,
|
||||
wechatPayEnabled: cfg.wechatPayEnabled,
|
||||
mockSms: cfg.mockSms,
|
||||
mockWechat: cfg.mockWechat,
|
||||
wxAuthorize: cfg.wxAuthorize,
|
||||
/** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */
|
||||
tencentLbsKey: cfg.tencentLbsKey || undefined,
|
||||
miniHome: {
|
||||
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
|
||||
footerUrl: footer || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Body, Controller, Headers, Post, UseGuards } from '@nestjs/common';
|
||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { ClientErrorService } from './client-error.service';
|
||||
import { ReportClientErrorDto } from './dto/client-error.dto';
|
||||
|
||||
@Controller('common')
|
||||
export class ClientErrorController {
|
||||
constructor(private readonly clientErrors: ClientErrorService) {}
|
||||
|
||||
/**
|
||||
* 客户端报错上报(可匿名)。
|
||||
* fatal/error → Nest 日志 + 落库 + 企微;warn → 仅日志/落库。
|
||||
*/
|
||||
@Post('client-errors')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
report(
|
||||
@Body() dto: ReportClientErrorDto,
|
||||
@CurrentUser() user: AuthUser | undefined,
|
||||
@Headers('x-client-app') clientApp?: string,
|
||||
) {
|
||||
return this.clientErrors.report(dto, user, clientApp);
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { ClientApp, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { AlertService } from '../../common/alert/alert.service';
|
||||
import type { AlertLevel } from '../../common/alert/alert.constants';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import type { ReportClientErrorDto } from './dto/client-error.dto';
|
||||
|
||||
const WECOM_LEVELS = new Set(['fatal', 'error']);
|
||||
|
||||
@Injectable()
|
||||
export class ClientErrorService {
|
||||
private readonly logger = new Logger(ClientErrorService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) {
|
||||
const clientApp = (dto.clientApp || headerClientApp || user?.clientApp || 'UNKNOWN').slice(0, 32);
|
||||
const message = dto.message.trim().slice(0, 1000);
|
||||
const stack = dto.stack?.trim().slice(0, 4000);
|
||||
const pagePath = dto.pagePath?.trim().slice(0, 128);
|
||||
const fingerprint = `${dto.level}|${dto.category}|${message.slice(0, 120)}`;
|
||||
const anonymous = user?.actorId == null;
|
||||
const skipWecom = shouldSkipWecomClientErrorAlert({
|
||||
message,
|
||||
pagePath,
|
||||
clientApp,
|
||||
anonymous,
|
||||
category: dto.category,
|
||||
});
|
||||
|
||||
const logLine = {
|
||||
level: dto.level,
|
||||
category: dto.category,
|
||||
message,
|
||||
pagePath,
|
||||
clientApp,
|
||||
actorType: user?.actorType,
|
||||
actorId: user?.actorId != null ? String(user.actorId) : undefined,
|
||||
stack: stack?.slice(0, 800),
|
||||
extra: dto.extra,
|
||||
skipWecom,
|
||||
};
|
||||
|
||||
if (dto.level === 'fatal') {
|
||||
this.logger.error(`[client_error] ${JSON.stringify(logLine)}`);
|
||||
} else if (dto.level === 'error') {
|
||||
this.logger.error(`[client_error] ${JSON.stringify(logLine)}`);
|
||||
} else {
|
||||
this.logger.warn(`[client_error] ${JSON.stringify(logLine)}`);
|
||||
}
|
||||
|
||||
// 落库便于 HQ 排查(匿名也可写,userId 为空)
|
||||
try {
|
||||
await this.prisma.logUserAnalytics.create({
|
||||
data: {
|
||||
userId:
|
||||
user?.actorType === 'USER' && user.actorId != null ? user.actorId : null,
|
||||
eventName: 'client_error',
|
||||
clientApp: isClientApp(clientApp) ? clientApp : null,
|
||||
pagePath: pagePath || null,
|
||||
extraJson: {
|
||||
level: dto.level,
|
||||
category: dto.category,
|
||||
message,
|
||||
stack: stack || null,
|
||||
actorType: user?.actorType ?? null,
|
||||
actorId: user?.actorId != null ? String(user.actorId) : null,
|
||||
storeId: user?.storeId != null ? String(user.storeId) : null,
|
||||
skipWecom,
|
||||
...(dto.extra && typeof dto.extra === 'object' ? { clientExtra: dto.extra } : {}),
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`client_error persist failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (WECOM_LEVELS.has(dto.level) && !skipWecom) {
|
||||
const alertLevel: AlertLevel = dto.level === 'fatal' ? 'P0' : 'P1';
|
||||
this.alert.notify({
|
||||
level: alertLevel,
|
||||
category: 'client_error',
|
||||
title: `客户端报错 [${dto.level}/${dto.category}]`,
|
||||
detail: [
|
||||
`端:${clientApp}`,
|
||||
pagePath ? `页面:${pagePath}` : null,
|
||||
user?.actorId != null
|
||||
? `用户:${user.actorType}:${String(user.actorId)}`
|
||||
: '用户:匿名',
|
||||
`消息:${message}`,
|
||||
stack ? `堆栈:${stack.slice(0, 600)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
dedupeKey: `client_error|${fingerprint}`,
|
||||
dedupeTtlSec: 600,
|
||||
});
|
||||
} else if (skipWecom && WECOM_LEVELS.has(dto.level)) {
|
||||
this.logger.log(
|
||||
`[client_error] skip WeCom alert (likely mini-program audit noise): ${message.slice(0, 160)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤企微推送:微信小程序审核机 / 自动化探测常见噪声。
|
||||
* 仍落库与写服务端日志,仅跳过 webhook。
|
||||
*
|
||||
* 典型特征(与本次协议页报错一致):
|
||||
* - 匿名 USER_MINI
|
||||
* - navigateTo/redirectTo 等 page … is not found(常带 .html,Taro H5 路径形态)
|
||||
*/
|
||||
export function shouldSkipWecomClientErrorAlert(input: {
|
||||
message: string;
|
||||
pagePath?: string | null;
|
||||
clientApp: string;
|
||||
anonymous: boolean;
|
||||
category?: string;
|
||||
}): boolean {
|
||||
const msg = input.message || '';
|
||||
const app = input.clientApp || '';
|
||||
const isMini = app === 'USER_MINI' || app === 'PARTNER_MINI' || app === 'HQ_MINI';
|
||||
|
||||
// 路由页不存在:审核机点协议/隐私链接触发最常见
|
||||
const isNavPageMissing =
|
||||
/(navigateTo|redirectTo|reLaunch|switchTab):fail/i.test(msg) &&
|
||||
/is not found/i.test(msg);
|
||||
|
||||
// Taro 把路径拼成 *.html 的形态,几乎不可能是真·原生页路径
|
||||
const isTaroHtmlPagePath =
|
||||
/\.html(\b|"|')/i.test(msg) && /is not found|page /i.test(msg);
|
||||
|
||||
if (isMini && input.anonymous && (isNavPageMissing || isTaroHtmlPagePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 即使带登录态,纯 *.html not found 也视为框架/探测噪声
|
||||
if (isMini && isTaroHtmlPagePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isClientApp(v: string): v is ClientApp {
|
||||
return [
|
||||
'USER_MINI',
|
||||
'USER_H5',
|
||||
'PARTNER_MINI',
|
||||
'PARTNER_H5',
|
||||
'HQ_MINI',
|
||||
'HQ_WEB',
|
||||
'SHOP_H5',
|
||||
].includes(v);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { SystemConfigModule } from '../../common/system-config/system-config.module';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { EventService } from './event.service';
|
||||
import { TicketService } from './ticket.service';
|
||||
import { SupportTicketService } from './support-ticket.service';
|
||||
import { ThirdPartyLogService } from './third-party-log.service';
|
||||
import { ResourceController } from './resource.controller';
|
||||
import { EventController } from './event.controller';
|
||||
import { TicketController } from './ticket.controller';
|
||||
import { ThirdPartyLogController } from './third-party-log.controller';
|
||||
import { WechatController } from './wechat.controller';
|
||||
import { ClientConfigController } from './client-config.controller';
|
||||
import { LbsController } from './lbs.controller';
|
||||
import { WechatLocationService } from './wechat-location.service';
|
||||
import { ClientErrorService } from './client-error.service';
|
||||
import { ClientErrorController } from './client-error.controller';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
|
||||
controllers: [
|
||||
ResourceController,
|
||||
EventController,
|
||||
TicketController,
|
||||
ThirdPartyLogController,
|
||||
WechatController,
|
||||
ClientConfigController,
|
||||
LbsController,
|
||||
ClientErrorController,
|
||||
],
|
||||
providers: [
|
||||
ResourceService,
|
||||
EventService,
|
||||
TicketService,
|
||||
SupportTicketService,
|
||||
ThirdPartyLogService,
|
||||
WechatLocationService,
|
||||
ClientErrorService,
|
||||
],
|
||||
exports: [ResourceService, EventService, TicketService, SupportTicketService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** 客户端报错严重级别 */
|
||||
export const CLIENT_ERROR_LEVELS = ['fatal', 'error', 'warn'] as const;
|
||||
export type ClientErrorLevel = (typeof CLIENT_ERROR_LEVELS)[number];
|
||||
|
||||
/** 客户端报错类别 */
|
||||
export const CLIENT_ERROR_CATEGORIES = [
|
||||
'js_error',
|
||||
'unhandled_rejection',
|
||||
'api_error',
|
||||
'network',
|
||||
'render',
|
||||
'bridge',
|
||||
'other',
|
||||
] as const;
|
||||
export type ClientErrorCategory = (typeof CLIENT_ERROR_CATEGORIES)[number];
|
||||
|
||||
export class ReportClientErrorDto {
|
||||
@IsIn(CLIENT_ERROR_LEVELS)
|
||||
level!: ClientErrorLevel;
|
||||
|
||||
@IsIn(CLIENT_ERROR_CATEGORIES)
|
||||
category!: ClientErrorCategory;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
message!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
stack?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
pagePath?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
clientApp?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UploadTokenDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
bizType: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
|
||||
mediaType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export class UploadFileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
bizType: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
|
||||
mediaType: string;
|
||||
}
|
||||
|
||||
export class RegisterResourceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
ownerType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
ownerId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
bizType: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
|
||||
mediaType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
ossKey: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
url: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ossBucket?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileName?: string;
|
||||
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateResourceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
url?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
|
||||
mediaType?: string;
|
||||
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DELETED'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class CreateEventDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
eventType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
param1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
param1Desc?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
param2?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
param2Desc?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
param3?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
param3Desc?: string;
|
||||
|
||||
@IsOptional()
|
||||
amount1?: number;
|
||||
|
||||
@IsOptional()
|
||||
amount2?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
|
||||
@IsOptional()
|
||||
extraJson?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class CreateTicketDto {
|
||||
@IsString()
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
|
||||
ticketType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
param1?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
param1Desc?: string;
|
||||
|
||||
@IsOptional()
|
||||
extraJson?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class AdminCreateTicketDto {
|
||||
@IsString()
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND', 'PACKAGE_DISPUTE'])
|
||||
ticketType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
orderNo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
|
||||
@IsOptional()
|
||||
evidenceUrls?: string[];
|
||||
}
|
||||
|
||||
export class UpdateTicketStatusDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
status: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class AssignTicketDto {
|
||||
@IsString()
|
||||
@IsIn(['HQ', 'PARTNER', 'SYSTEM'])
|
||||
operatorType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
operatorId: string;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
|
||||
export class ResourceListQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bizType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DELETED'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class EventListQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eventType?: string;
|
||||
}
|
||||
|
||||
export class EventTimelineQueryDto {
|
||||
@IsString()
|
||||
refType: string;
|
||||
|
||||
@IsString()
|
||||
refId: string;
|
||||
}
|
||||
|
||||
export class TicketListQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ticketType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refId?: string;
|
||||
}
|
||||
|
||||
export class ThirdPartyLogQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
provider?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scene?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refId?: string;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class SupportTicketListQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
|
||||
ticketType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
|
||||
export class CreateSupportTicketDto {
|
||||
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
|
||||
ticketType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class RejectSupportTicketDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(512)
|
||||
rejectReason: string;
|
||||
}
|
||||
|
||||
export class SupportTicketRemarkDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { EventService } from './event.service';
|
||||
import { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto';
|
||||
import { CreateEventDto } from './dto/common-mutate.dto';
|
||||
|
||||
@Controller('common/events')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class EventController {
|
||||
constructor(private readonly service: EventService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateEventDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: EventListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get('timeline')
|
||||
timeline(@Query() query: EventTimelineQueryDto) {
|
||||
return this.service.timeline(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { ActorType, EventType, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto';
|
||||
import type { CreateEventDto } from './dto/common-mutate.dto';
|
||||
|
||||
@Injectable()
|
||||
export class EventService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(dto: CreateEventDto) {
|
||||
const event = await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: dto.eventType as EventType,
|
||||
refType: dto.refType,
|
||||
refId: BigInt(dto.refId),
|
||||
actorType: dto.actorType as ActorType | undefined,
|
||||
actorId: dto.actorId ? BigInt(dto.actorId) : undefined,
|
||||
status: dto.status,
|
||||
param1: dto.param1,
|
||||
param1Desc: dto.param1Desc,
|
||||
param2: dto.param2,
|
||||
param2Desc: dto.param2Desc,
|
||||
param3: dto.param3,
|
||||
param3Desc: dto.param3Desc,
|
||||
amount1: dto.amount1,
|
||||
amount2: dto.amount2,
|
||||
remark: dto.remark,
|
||||
extraJson: dto.extraJson as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(event);
|
||||
}
|
||||
|
||||
async list(query: EventListQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonEventWhereInput = {};
|
||||
if (query.refType) where.refType = query.refType;
|
||||
if (query.refId) where.refId = BigInt(query.refId);
|
||||
if (query.eventType) where.eventType = query.eventType as Prisma.EnumEventTypeFilter['equals'];
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonEvent.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.commonEvent.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async timeline(query: EventTimelineQueryDto) {
|
||||
const items = await this.prisma.commonEvent.findMany({
|
||||
where: { refType: query.refType, refId: BigInt(query.refId) },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
return serializeBigInt(items);
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const event = await this.prisma.commonEvent.findUnique({ where: { id } });
|
||||
if (!event) throw new NotFoundException('事件不存在');
|
||||
return serializeBigInt(event);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
ServiceUnavailableException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||||
|
||||
function parseCoord(raw: string | undefined, label: string): number | undefined {
|
||||
if (raw == null || raw === '') return undefined;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) throw new BadRequestException(`${label}无效`);
|
||||
return n;
|
||||
}
|
||||
|
||||
@Controller('common/lbs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LbsController {
|
||||
constructor(private readonly tencentLbs: TencentLbsProvider) {}
|
||||
|
||||
@Get('suggest')
|
||||
async suggest(
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('region') region?: string,
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
) {
|
||||
if (!this.tencentLbs.isEnabled()) {
|
||||
throw new ServiceUnavailableException('未配置腾讯位置服务 Key(TENCENT_LBS_KEY)');
|
||||
}
|
||||
const q = (keyword ?? '').trim();
|
||||
if (!q) return { items: [] };
|
||||
const latitude = parseCoord(lat, '纬度');
|
||||
const longitude = parseCoord(lng, '经度');
|
||||
const result = await this.tencentLbs.suggestPlaces(q, {
|
||||
region: region?.trim() || undefined,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
if (result.error && !result.items.length) {
|
||||
throw new BadRequestException(result.error);
|
||||
}
|
||||
return { items: result.items };
|
||||
}
|
||||
|
||||
@Get('nearby')
|
||||
async nearby(@Query('lat') lat?: string, @Query('lng') lng?: string, @Query('radius') radius?: string) {
|
||||
if (!this.tencentLbs.isEnabled()) {
|
||||
throw new ServiceUnavailableException('未配置腾讯位置服务 Key(TENCENT_LBS_KEY)');
|
||||
}
|
||||
const latitude = parseCoord(lat, '纬度');
|
||||
const longitude = parseCoord(lng, '经度');
|
||||
if (latitude == null || longitude == null) {
|
||||
throw new BadRequestException('请提供 lat、lng');
|
||||
}
|
||||
const r = radius != null && radius !== '' ? Number(radius) : 1000;
|
||||
const result = await this.tencentLbs.exploreNearby(latitude, longitude, Number.isFinite(r) ? r : 1000);
|
||||
if (result.error && !result.items.length) {
|
||||
throw new BadRequestException(result.error);
|
||||
}
|
||||
return { items: result.items };
|
||||
}
|
||||
|
||||
@Get('reverse')
|
||||
async reverse(@Query('lat') lat?: string, @Query('lng') lng?: string) {
|
||||
if (!this.tencentLbs.isEnabled()) {
|
||||
throw new ServiceUnavailableException('未配置腾讯位置服务 Key(TENCENT_LBS_KEY)');
|
||||
}
|
||||
const latitude = parseCoord(lat, '纬度');
|
||||
const longitude = parseCoord(lng, '经度');
|
||||
if (latitude == null || longitude == null) {
|
||||
throw new BadRequestException('请提供 lat、lng');
|
||||
}
|
||||
const result = await this.tencentLbs.reverseGeocodeDetail(latitude, longitude);
|
||||
if (!result.item) {
|
||||
throw new BadRequestException(result.error || '逆地理编码失败');
|
||||
}
|
||||
return {
|
||||
latitude: result.item.latitude,
|
||||
longitude: result.item.longitude,
|
||||
address: result.item.address,
|
||||
name: result.item.name,
|
||||
province: result.item.province,
|
||||
city: result.item.city,
|
||||
district: result.item.district,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { JwtAuthGuard, type AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { ResourceService, type OssUploadActor } from './resource.service';
|
||||
import { ResourceListQueryDto } from './dto/common-query.dto';
|
||||
import { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
|
||||
|
||||
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function resolveUploadActor(user?: AuthUser): OssUploadActor | undefined {
|
||||
if (!user) return undefined;
|
||||
return {
|
||||
refType: user.actorType,
|
||||
refId: user.actorId,
|
||||
clientApp: user.clientApp,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('common/resources')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ResourceController {
|
||||
constructor(private readonly service: ResourceService) {}
|
||||
|
||||
@Post('upload-token')
|
||||
uploadToken(@CurrentUser() user: AuthUser, @Body() dto: UploadTokenDto) {
|
||||
return this.service.getUploadToken(dto, resolveUploadActor(user));
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES) },
|
||||
}),
|
||||
)
|
||||
upload(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: UploadFileDto,
|
||||
) {
|
||||
return this.service.uploadFile(file, dto, resolveUploadActor(user));
|
||||
}
|
||||
|
||||
@Post()
|
||||
register(@Body() dto: RegisterResourceDto) {
|
||||
return this.service.register(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ResourceListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateResourceDto) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import { logOssUpload, type OssActorRef } from '../../integrations/oss/oss-log.util';
|
||||
import type { ResourceListQueryDto } from './dto/common-query.dto';
|
||||
import type { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
|
||||
|
||||
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export type OssUploadActor = OssActorRef & {
|
||||
clientApp?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ResourceService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||
) {}
|
||||
|
||||
getUploadToken(dto: UploadTokenDto, actor?: OssUploadActor) {
|
||||
try {
|
||||
const result = this.oss.getUploadToken(dto);
|
||||
void logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_TOKEN',
|
||||
actorRef: actor,
|
||||
requestBody: {
|
||||
bizType: dto.bizType,
|
||||
mediaType: dto.mediaType,
|
||||
fileName: dto.fileName,
|
||||
clientApp: actor?.clientApp,
|
||||
},
|
||||
responseBody: {
|
||||
bucket: result.bucket,
|
||||
ossKey: result.ossKey,
|
||||
url: result.url,
|
||||
mock: result.mock ?? false,
|
||||
},
|
||||
externalNo: result.ossKey,
|
||||
status: 'SUCCESS',
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
void logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_TOKEN',
|
||||
actorRef: actor,
|
||||
requestBody: {
|
||||
bizType: dto.bizType,
|
||||
mediaType: dto.mediaType,
|
||||
fileName: dto.fileName,
|
||||
clientApp: actor?.clientApp,
|
||||
},
|
||||
status: 'FAILED',
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
file: Express.Multer.File | undefined,
|
||||
dto: UploadFileDto,
|
||||
actor?: OssUploadActor,
|
||||
) {
|
||||
const baseRequest = {
|
||||
bizType: dto.bizType,
|
||||
mediaType: dto.mediaType,
|
||||
clientApp: actor?.clientApp,
|
||||
};
|
||||
|
||||
if (!file) {
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
actorRef: actor,
|
||||
requestBody: baseRequest,
|
||||
status: 'FAILED',
|
||||
errorMessage: '请选择要上传的文件',
|
||||
});
|
||||
throw new BadRequestException('请选择要上传的文件');
|
||||
}
|
||||
|
||||
const maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
|
||||
const requestBody = {
|
||||
...baseRequest,
|
||||
fileName: file.originalname || 'upload.bin',
|
||||
fileSize: file.size,
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
|
||||
if (file.size > maxUploadBytes) {
|
||||
const message = `文件不能超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB`;
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
actorRef: actor,
|
||||
requestBody,
|
||||
status: 'FAILED',
|
||||
errorMessage: message,
|
||||
});
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) {
|
||||
throw new BadRequestException('头像仅支持图片文件');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.oss.putObject({
|
||||
bizType: dto.bizType,
|
||||
mediaType: dto.mediaType,
|
||||
fileName: file.originalname || 'upload.bin',
|
||||
buffer: file.buffer,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
actorRef: actor,
|
||||
requestBody,
|
||||
responseBody: {
|
||||
bucket: result.bucket,
|
||||
region: result.region,
|
||||
ossKey: result.ossKey,
|
||||
url: result.url,
|
||||
mock: result.mock ?? false,
|
||||
},
|
||||
externalNo: result.ossKey,
|
||||
status: 'SUCCESS',
|
||||
});
|
||||
if (actor?.refType === 'USER' && dto.bizType === 'AVATAR' && dto.mediaType === 'IMAGE') {
|
||||
const resource = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'USER',
|
||||
ownerId: actor.refId,
|
||||
bizType: 'AVATAR',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: result.bucket,
|
||||
ossKey: result.ossKey,
|
||||
url: result.url,
|
||||
fileName: file.originalname || 'avatar',
|
||||
fileSize: BigInt(file.size),
|
||||
mimeType: file.mimetype,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
return serializeBigInt({ ...result, resourceId: resource.id });
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
actorRef: actor,
|
||||
requestBody,
|
||||
status: 'FAILED',
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getOwnedActiveAvatar(resourceId: bigint, userId: bigint) {
|
||||
const resource = await this.prisma.commonResource.findFirst({
|
||||
where: {
|
||||
id: resourceId,
|
||||
ownerType: 'USER',
|
||||
ownerId: userId,
|
||||
bizType: 'AVATAR',
|
||||
mediaType: 'IMAGE',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户');
|
||||
return resource;
|
||||
}
|
||||
|
||||
async getOwnedActiveAvatarByUrl(url: string, userId: bigint) {
|
||||
const resource = await this.prisma.commonResource.findFirst({
|
||||
where: {
|
||||
url,
|
||||
ownerType: 'USER',
|
||||
ownerId: userId,
|
||||
bizType: 'AVATAR',
|
||||
mediaType: 'IMAGE',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户');
|
||||
return resource;
|
||||
}
|
||||
|
||||
async register(dto: RegisterResourceDto) {
|
||||
const resource = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: dto.ownerType as ResourceOwnerType,
|
||||
ownerId: BigInt(dto.ownerId),
|
||||
bizType: dto.bizType as ResourceBizType,
|
||||
mediaType: dto.mediaType as ResourceMediaType,
|
||||
ossBucket: dto.ossBucket ?? process.env.OSS_BUCKET ?? 'mock-dukang',
|
||||
ossKey: dto.ossKey,
|
||||
url: dto.url || this.oss.buildPublicUrl(dto.ossKey),
|
||||
fileName: dto.fileName,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(resource);
|
||||
}
|
||||
|
||||
async list(query: ResourceListQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonResourceWhereInput = {
|
||||
status: (query.status ?? 'ACTIVE') as Prisma.EnumResourceStatusFilter['equals'],
|
||||
};
|
||||
if (query.ownerType) where.ownerType = query.ownerType as Prisma.EnumResourceOwnerTypeFilter['equals'];
|
||||
if (query.ownerId) where.ownerId = BigInt(query.ownerId);
|
||||
if (query.bizType) where.bizType = query.bizType as Prisma.EnumResourceBizTypeFilter['equals'];
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonResource.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.commonResource.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const resource = await this.prisma.commonResource.findUnique({ where: { id } });
|
||||
if (!resource) throw new NotFoundException('资源不存在');
|
||||
return serializeBigInt(resource);
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateResourceDto) {
|
||||
await this.detail(id);
|
||||
const resource = await this.prisma.commonResource.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}),
|
||||
...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' | 'FILE' } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DELETED' } : {}),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(resource);
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.detail(id);
|
||||
await this.prisma.commonResource.update({
|
||||
where: { id },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { AlertService } from '../../common/alert/alert.service';
|
||||
import type {
|
||||
CreateSupportTicketDto,
|
||||
RejectSupportTicketDto,
|
||||
SupportTicketListQueryDto,
|
||||
SupportTicketRemarkDto,
|
||||
} from './dto/support-ticket.dto';
|
||||
|
||||
function generateSupportTicketNo() {
|
||||
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SupportTicketService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateSupportTicketDto,
|
||||
creator: { id: bigint; name: string },
|
||||
) {
|
||||
const ticket = await this.prisma.commonSupportTicket.create({
|
||||
data: {
|
||||
ticketNo: generateSupportTicketNo(),
|
||||
ticketType: dto.ticketType as SupportTicketType,
|
||||
status: 'PENDING_REVIEW',
|
||||
title: dto.title.trim(),
|
||||
content: dto.content?.trim() || null,
|
||||
remark: dto.remark?.trim() || null,
|
||||
creatorId: creator.id,
|
||||
creatorName: creator.name,
|
||||
},
|
||||
});
|
||||
this.alert.notify({
|
||||
level: 'P2',
|
||||
category: 'ops',
|
||||
title: '新建技术支持工单',
|
||||
detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n标题 ${ticket.title}\n创建人 ${creator.name}`,
|
||||
dedupeKey: `support_ticket_create|${ticket.ticketNo}`,
|
||||
});
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
async list(query: SupportTicketListQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonSupportTicketWhereInput = {};
|
||||
if (query.ticketType) {
|
||||
where.ticketType = query.ticketType as SupportTicketType;
|
||||
}
|
||||
if (query.status) {
|
||||
where.status = query.status as SupportTicketStatus;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonSupportTicket.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.commonSupportTicket.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('技术支持工单不存在');
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
private async getOrThrow(id: bigint) {
|
||||
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('技术支持工单不存在');
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/** 最高管理员评审通过 → 进入开发 */
|
||||
async approve(
|
||||
id: bigint,
|
||||
reviewer: { id: bigint; name: string },
|
||||
dto?: SupportTicketRemarkDto,
|
||||
) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('仅待评审工单可通过评审');
|
||||
}
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'DEVELOPING',
|
||||
reviewerId: reviewer.id,
|
||||
reviewerName: reviewer.name,
|
||||
reviewedAt: new Date(),
|
||||
remark: dto?.remark?.trim() || ticket.remark,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
/** 最高管理员评审驳回 */
|
||||
async reject(
|
||||
id: bigint,
|
||||
reviewer: { id: bigint; name: string },
|
||||
dto: RejectSupportTicketDto,
|
||||
) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('仅待评审工单可驳回');
|
||||
}
|
||||
const reason = dto.rejectReason.trim();
|
||||
if (!reason) throw new BadRequestException('请填写驳回理由');
|
||||
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
rejectReason: reason,
|
||||
reviewerId: reviewer.id,
|
||||
reviewerName: reviewer.name,
|
||||
reviewedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
/** 开发完成 → 测试 */
|
||||
async startTesting(id: bigint, dto?: SupportTicketRemarkDto) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'DEVELOPING') {
|
||||
throw new BadRequestException('仅开发中工单可转入测试');
|
||||
}
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'TESTING',
|
||||
remark: dto?.remark?.trim() || ticket.remark,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
/** 测试通过 */
|
||||
async pass(id: bigint, dto?: SupportTicketRemarkDto) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'TESTING') {
|
||||
throw new BadRequestException('仅测试中工单可标记通过');
|
||||
}
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PASSED',
|
||||
remark: dto?.remark?.trim() || ticket.remark,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { ThirdPartyLogService } from './third-party-log.service';
|
||||
import { ThirdPartyLogQueryDto } from './dto/common-query.dto';
|
||||
|
||||
@Controller('common/third-party-logs')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class ThirdPartyLogController {
|
||||
constructor(private readonly service: ThirdPartyLogService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ThirdPartyLogQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { 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 { ThirdPartyLogQueryDto } from './dto/common-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ThirdPartyLogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: ThirdPartyLogQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.LogThirdPartyWhereInput = {};
|
||||
if (query.provider) where.provider = query.provider as Prisma.EnumThirdPartyProviderFilter['equals'];
|
||||
if (query.scene) where.scene = { contains: query.scene };
|
||||
if (query.refType) where.refType = query.refType;
|
||||
if (query.refId) where.refId = BigInt(query.refId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.logThirdParty.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.logThirdParty.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const log = await this.prisma.logThirdParty.findUnique({ where: { id } });
|
||||
if (!log) throw new NotFoundException('日志不存在');
|
||||
return serializeBigInt(log);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { TicketService } from './ticket.service';
|
||||
import { TicketListQueryDto } from './dto/common-query.dto';
|
||||
import { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
|
||||
|
||||
@Controller('common/tickets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TicketController {
|
||||
constructor(private readonly service: TicketService) {}
|
||||
|
||||
/** 总部建单;用户售后请走 /trade/orders/:id/after-sale-tickets */
|
||||
@Post()
|
||||
@UseGuards(HqAuthGuard)
|
||||
create(@Body() dto: CreateTicketDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: TicketListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@UseGuards(HqAuthGuard)
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateTicketStatusDto) {
|
||||
return this.service.updateStatus(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/assign')
|
||||
@UseGuards(HqAuthGuard)
|
||||
assign(@Param('id') id: string, @Body() dto: AssignTicketDto) {
|
||||
return this.service.assign(BigInt(id), dto);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { ActorType, TicketType } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { AlertService } from '../../common/alert/alert.service';
|
||||
import type { TicketListQueryDto } from './dto/common-query.dto';
|
||||
import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
|
||||
|
||||
function generateTicketNo() {
|
||||
return `TK${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TicketService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateTicketDto) {
|
||||
const ticket = await this.prisma.commonTicket.create({
|
||||
data: {
|
||||
ticketNo: generateTicketNo(),
|
||||
ticketType: dto.ticketType as TicketType,
|
||||
refType: dto.refType,
|
||||
refId: BigInt(dto.refId),
|
||||
remark: dto.remark,
|
||||
param1: dto.param1,
|
||||
param1Desc: dto.param1Desc,
|
||||
extraJson: dto.extraJson ? (dto.extraJson as Prisma.InputJsonValue) : undefined,
|
||||
},
|
||||
});
|
||||
this.alert.notify({
|
||||
level: 'P2',
|
||||
category: 'ops',
|
||||
title: '新建售后工单',
|
||||
detail: `工单 ${ticket.ticketNo}\n类型 ${ticket.ticketType}\n关联 ${ticket.refType}:${ticket.refId}\n${dto.remark ?? ''}`,
|
||||
dedupeKey: `ticket_create|${ticket.ticketNo}`,
|
||||
});
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
async list(query: TicketListQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonTicketWhereInput = {};
|
||||
if (query.ticketType) where.ticketType = query.ticketType as Prisma.EnumTicketTypeFilter['equals'];
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.refType) where.refType = query.refType;
|
||||
if (query.refId) where.refId = BigInt(query.refId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonTicket.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.commonTicket.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('工单不存在');
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
async updateStatus(id: bigint, dto: UpdateTicketStatusDto) {
|
||||
await this.detail(id);
|
||||
const ticket = await this.prisma.commonTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: dto.status,
|
||||
remark: dto.remark,
|
||||
completedAt: ['COMPLETED', 'CLOSED', 'RESOLVED'].includes(dto.status) ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
async updateExtraJson(id: bigint, extraJson: Record<string, unknown>, status?: string, remark?: string) {
|
||||
await this.detail(id);
|
||||
const ticket = await this.prisma.commonTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
extraJson: extraJson as Prisma.InputJsonValue,
|
||||
...(status
|
||||
? {
|
||||
status,
|
||||
completedAt: ['COMPLETED', 'CLOSED', 'RESOLVED'].includes(status) ? new Date() : undefined,
|
||||
}
|
||||
: {}),
|
||||
...(remark !== undefined ? { remark } : {}),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
async assign(id: bigint, dto: AssignTicketDto) {
|
||||
await this.detail(id);
|
||||
const ticket = await this.prisma.commonTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
operatorType: dto.operatorType as ActorType,
|
||||
operatorId: BigInt(dto.operatorId),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||||
import { logWechatAuth, type WechatActorRef } from '../../integrations/wechat/wechat-log.util';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
|
||||
export type ReportWechatLocationInput = {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
status: 'success' | 'fail';
|
||||
errMsg?: string;
|
||||
clientApp?: string;
|
||||
userId?: bigint;
|
||||
};
|
||||
|
||||
export type ReportWechatLocationResult = {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity: boolean;
|
||||
thirdPartyLogIds: {
|
||||
location?: string;
|
||||
geocode?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeCityName(name: string) {
|
||||
return name.replace(/市$/, '').trim();
|
||||
}
|
||||
|
||||
function matchOpenCity(
|
||||
cities: Array<{ code: string; name: string; province: string }>,
|
||||
province: string,
|
||||
city: string,
|
||||
) {
|
||||
const cityNorm = normalizeCityName(city);
|
||||
return cities.find((c) => {
|
||||
const nameNorm = normalizeCityName(c.name);
|
||||
if (nameNorm !== cityNorm && c.name !== city && c.name !== `${cityNorm}市`) return false;
|
||||
if (c.province && province && c.province !== province) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WechatLocationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tencentLbs: TencentLbsProvider,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
) {}
|
||||
|
||||
async reportLocation(input: ReportWechatLocationInput): Promise<ReportWechatLocationResult> {
|
||||
const actorRef: WechatActorRef | undefined = input.userId
|
||||
? { refType: 'USER', refId: input.userId }
|
||||
: undefined;
|
||||
const clientApp = (input.clientApp as ClientApp) || ClientApp.USER_H5;
|
||||
const thirdPartyLogIds: ReportWechatLocationResult['thirdPartyLogIds'] = {};
|
||||
|
||||
const locationLogId = await logWechatAuth(this.prisma, {
|
||||
scene: 'GET_LOCATION',
|
||||
requestBody: {
|
||||
sdk: input.sdk,
|
||||
status: input.status,
|
||||
...(input.latitude != null && input.longitude != null
|
||||
? {
|
||||
latitude: Number(input.latitude.toFixed(3)),
|
||||
longitude: Number(input.longitude.toFixed(3)),
|
||||
}
|
||||
: {}),
|
||||
...(input.errMsg ? { errMsg: input.errMsg.slice(0, 200) } : {}),
|
||||
},
|
||||
responseBody: { reported: true },
|
||||
status: input.status === 'success' ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: input.status === 'fail' ? input.errMsg?.slice(0, 512) : undefined,
|
||||
actorRef,
|
||||
});
|
||||
thirdPartyLogIds.location = locationLogId.toString();
|
||||
|
||||
if (input.status !== 'success' || input.latitude == null || input.longitude == null) {
|
||||
return { openCity: false, thirdPartyLogIds };
|
||||
}
|
||||
|
||||
const geo = await this.tencentLbs.reverseGeocode(input.latitude, input.longitude, actorRef);
|
||||
if (geo) {
|
||||
thirdPartyLogIds.geocode = geo.logId.toString();
|
||||
}
|
||||
if (!geo) {
|
||||
return { openCity: false, thirdPartyLogIds };
|
||||
}
|
||||
|
||||
const openCities = await this.prisma.commonCity.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { code: true, name: true, province: true },
|
||||
});
|
||||
const matched = matchOpenCity(openCities, geo.province, geo.city);
|
||||
|
||||
const result: ReportWechatLocationResult = {
|
||||
province: geo.province,
|
||||
city: geo.city,
|
||||
district: geo.district,
|
||||
cityCode: matched?.code,
|
||||
cityName: matched?.name ?? `${geo.city}市`,
|
||||
openCity: !!matched,
|
||||
thirdPartyLogIds,
|
||||
};
|
||||
|
||||
if (input.userId) {
|
||||
const mapLogId = geo.logId;
|
||||
this.analyticsService.trackOneSafe(input.userId, clientApp, {
|
||||
eventName: 'wechat_location',
|
||||
refType: 'THIRD_PARTY_LOG',
|
||||
refId: mapLogId,
|
||||
extraJson: {
|
||||
sdk: input.sdk,
|
||||
province: geo.province,
|
||||
city: geo.city,
|
||||
district: geo.district,
|
||||
openCity: !!matched,
|
||||
cityCode: matched?.code,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async reportChooseImage(input: {
|
||||
status: 'success' | 'fail';
|
||||
errMsg?: string;
|
||||
sourceType?: string;
|
||||
stage?: string;
|
||||
pageUrl?: string;
|
||||
actorRef?: WechatActorRef;
|
||||
}) {
|
||||
const logId = await logWechatAuth(this.prisma, {
|
||||
scene: 'CHOOSE_IMAGE',
|
||||
requestUrl: input.pageUrl?.split('#')[0]?.slice(0, 512),
|
||||
requestBody: {
|
||||
status: input.status,
|
||||
...(input.sourceType ? { sourceType: input.sourceType } : {}),
|
||||
...(input.stage ? { stage: input.stage } : {}),
|
||||
...(input.errMsg ? { errMsg: input.errMsg.slice(0, 200) } : {}),
|
||||
},
|
||||
responseBody: { reported: true },
|
||||
status: input.status === 'success' ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: input.status === 'fail' ? input.errMsg?.slice(0, 512) : undefined,
|
||||
actorRef: input.actorRef,
|
||||
});
|
||||
return { ok: true, logId: logId.toString() };
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { BadRequestException, Body, Controller, Get, Inject, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
import type { Request } from 'express';
|
||||
import { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { wechatActorRefFromAuth } from '../../integrations/wechat/wechat-log.util';
|
||||
import { WechatLocationService } from './wechat-location.service';
|
||||
|
||||
class PhoneNumberDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['mini', 'h5'])
|
||||
@IsOptional()
|
||||
platform?: 'mini' | 'h5';
|
||||
}
|
||||
|
||||
class WechatLocationDto {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
latitude?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
longitude?: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['jssdk', 'geolocation'])
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
|
||||
@IsString()
|
||||
@IsIn(['success', 'fail'])
|
||||
status: 'success' | 'fail';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
errMsg?: string;
|
||||
}
|
||||
|
||||
class WechatChooseImageDto {
|
||||
@IsString()
|
||||
@IsIn(['success', 'fail'])
|
||||
status: 'success' | 'fail';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
errMsg?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sourceType?: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['jssdk', 'choose', 'read', 'empty'])
|
||||
@IsOptional()
|
||||
stage?: 'jssdk' | 'choose' | 'read' | 'empty';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pageUrl?: string;
|
||||
}
|
||||
|
||||
@Controller('common/wechat')
|
||||
export class WechatController {
|
||||
constructor(
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
private readonly locationService: WechatLocationService,
|
||||
) {}
|
||||
|
||||
@Get('jssdk-config')
|
||||
async jssdkConfig(@Query('url') url: string, @Req() req: Request) {
|
||||
if (!url) throw new BadRequestException('url 参数必填');
|
||||
const decoded = decodeURIComponent(url).split('#')[0];
|
||||
const pageUrl = this.normalizeJssdkUrl(decoded);
|
||||
const user = (req as Request & { user?: AuthUser }).user;
|
||||
const actorRef =
|
||||
user?.actorType === 'USER' ? { refType: 'USER', refId: user.actorId } : undefined;
|
||||
return this.wechat.createJssdkConfig(pageUrl, actorRef);
|
||||
}
|
||||
|
||||
private normalizeJssdkUrl(rawUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
parsed.hash = '';
|
||||
parsed.searchParams.delete('code');
|
||||
parsed.searchParams.delete('state');
|
||||
const query = parsed.searchParams.toString();
|
||||
return `${parsed.origin}${parsed.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
return rawUrl.split('#')[0];
|
||||
}
|
||||
}
|
||||
|
||||
@Get('oauth-url')
|
||||
oauthUrl(
|
||||
@Query('redirectUri') redirectUri: string,
|
||||
@Query('state') state: string,
|
||||
@Query('scope') scope?: string,
|
||||
) {
|
||||
if (!redirectUri || !state) throw new BadRequestException('redirectUri 与 state 必填');
|
||||
return { url: this.wechat.buildOAuthUrl(redirectUri, state, scope) };
|
||||
}
|
||||
|
||||
@Post('phone-number')
|
||||
phoneNumber(@Body() dto: PhoneNumberDto) {
|
||||
return this.wechat
|
||||
.getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
|
||||
.then((phone) => ({ phone }));
|
||||
}
|
||||
|
||||
@Post('location')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
reportLocation(@Req() req: Request, @Body() dto: WechatLocationDto) {
|
||||
const user = (req as Request & { user?: AuthUser }).user;
|
||||
const userId = user?.actorType === 'USER' ? user.actorId : undefined;
|
||||
const clientApp = (req.headers['x-client-app'] as string) || ClientApp.USER_H5;
|
||||
return this.locationService.reportLocation({
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
sdk: dto.sdk,
|
||||
status: dto.status,
|
||||
errMsg: dto.errMsg,
|
||||
clientApp,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('choose-image')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
reportChooseImage(@Req() req: Request, @Body() dto: WechatChooseImageDto) {
|
||||
const user = (req as Request & { user?: AuthUser }).user;
|
||||
return this.locationService.reportChooseImage({
|
||||
status: dto.status,
|
||||
errMsg: dto.errMsg,
|
||||
sourceType: dto.sourceType,
|
||||
stage: dto.stage,
|
||||
pageUrl: dto.pageUrl,
|
||||
actorRef: wechatActorRefFromAuth(user?.actorType, user?.actorId),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user