Ver Fonte

✨ feat(delivery-address): add delivery address management feature
- create delivery address entity with fields including name, phone, address and region information
- implement CRUD routes for delivery address management
- add delivery address service with methods for user address operations
- create address schema validation with Zod
- register delivery address API routes and client
- add delivery address relation to order entity

✨ feat(order): add order entity with delivery address relation
- create order entity with comprehensive order fields
- add delivery address relation to order entity
- include order amount breakdown fields (amount, cost, freight, discount, pay)
- add order status tracking fields (pay_state, state)
- include user, merchant, supplier relations

yourname há 4 meses atrás
pai
commit
5fd191307d

+ 6 - 1
src/client/api.ts

@@ -17,6 +17,7 @@ import type { AgentRoutes } from '@/server/api'
 import type { MerchantRoutes } from '@/server/api'
 import type { UserCardRoutes } from '@/server/api'
 import type { UserCardBalanceRecordRoutes } from '@/server/api'
+import type { DeliveryAddressRoutes } from '@/server/api'
 import { axiosFetch } from './utils/axios-fetch'
 
 // 创建客户端
@@ -90,4 +91,8 @@ export const userCardClient = hc<UserCardRoutes>('/', {
 
 export const userCardBalanceRecordClient = hc<UserCardBalanceRecordRoutes>('/', {
   fetch: axiosFetch,
-}).api.v1['user-card-balance-records']
+}).api.v1['user-card-balance-records']
+
+export const deliveryAddressClient = hc<DeliveryAddressRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['delivery-addresses']

+ 3 - 0
src/server/api.ts

@@ -19,6 +19,7 @@ import agentRoutes from './api/agents/index'
 import merchantRoutes from './api/merchants/index'
 import userCardRoutes from './api/user-cards/index'
 import userCardBalanceRecordRoutes from './api/user-card-balance-records/index'
+import deliveryAddressRoutes from './api/delivery-address/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
@@ -131,6 +132,7 @@ const agentApiRoutes = api.route('/api/v1/agents', agentRoutes)
 const merchantApiRoutes = api.route('/api/v1/merchants', merchantRoutes)
 const userCardApiRoutes = api.route('/api/v1/user-cards', userCardRoutes)
 const userCardBalanceRecordApiRoutes = api.route('/api/v1/user-card-balance-records', userCardBalanceRecordRoutes)
+const deliveryAddressApiRoutes = api.route('/api/v1/delivery-addresses', deliveryAddressRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
@@ -150,6 +152,7 @@ export type AgentRoutes = typeof agentApiRoutes
 export type MerchantRoutes = typeof merchantApiRoutes
 export type UserCardRoutes = typeof userCardApiRoutes
 export type UserCardBalanceRecordRoutes = typeof userCardBalanceRecordApiRoutes
+export type DeliveryAddressRoutes = typeof deliveryAddressApiRoutes
 
 app.route('/', api)
 export default app

+ 21 - 0
src/server/api/delivery-address/index.ts

@@ -0,0 +1,21 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { DeliveryAddress } from '@/server/modules/delivery-address/delivery-address.entity';
+import { DeliveryAddressSchema, CreateDeliveryAddressDto, UpdateDeliveryAddressDto } from '@/server/modules/delivery-address/delivery-address.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const deliveryAddressRoutes = createCrudRoutes({
+  entity: DeliveryAddress,
+  createSchema: CreateDeliveryAddressDto,
+  updateSchema: UpdateDeliveryAddressDto,
+  getSchema: DeliveryAddressSchema,
+  listSchema: DeliveryAddressSchema,
+  searchFields: ['name', 'phone', 'address'],
+  relations: ['user'],
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});
+
+export default deliveryAddressRoutes;

+ 2 - 1
src/server/data-source.ts

@@ -20,6 +20,7 @@ import { Agent } from "./modules/agent/agent.entity"
 import { Merchant } from "./modules/merchant/merchant.entity"
 import { UserCard } from "./modules/user-cards/user-card.entity"
 import { UserCardBalanceRecord } from "./modules/user-card-balance-records/user-card-balance-record.entity"
+import { DeliveryAddress } from "./modules/delivery-address/delivery-address.entity"
 
 export const AppDataSource = new DataSource({
   type: "mysql",
@@ -31,7 +32,7 @@ export const AppDataSource = new DataSource({
   entities: [
     User, Role, File, Advertisement, AdvertisementType,
     GoodsCategory, Goods, City, Config, ExpressCompany, Organization, Supplier, Card, Agent, Merchant,
-    UserCard, UserCardBalanceRecord,
+    UserCard, UserCardBalanceRecord, DeliveryAddress,
   ],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 54 - 0
src/server/modules/delivery-address/delivery-address.entity.ts

@@ -0,0 +1,54 @@
+import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { User } from '@/server/modules/users/user.entity';
+
+@Entity('delivery_address')
+export class DeliveryAddress {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'user_id', type: 'int', unsigned: true, comment: '用户id' })
+  userId!: number;
+
+  @Column({ name: 'name', type: 'varchar', length: 255, comment: '姓名' })
+  name!: string;
+
+  @Column({ name: 'phone', type: 'varchar', length: 255, comment: '手机号' })
+  phone!: string;
+
+  @Column({ name: 'address', type: 'varchar', length: 255, comment: '详细地址' })
+  address!: string;
+
+  @Column({ name: 'receiver_province', type: 'bigint', unsigned: true, default: 0, comment: '省' })
+  receiverProvince!: number;
+
+  @Column({ name: 'receiver_city', type: 'bigint', unsigned: true, default: 0, comment: '市' })
+  receiverCity!: number;
+
+  @Column({ name: 'receiver_district', type: 'bigint', unsigned: true, default: 0, comment: '区' })
+  receiverDistrict!: number;
+
+  @Column({ name: 'receiver_town', type: 'bigint', unsigned: true, default: 0, comment: '街道' })
+  receiverTown!: number;
+
+  @Column({ name: 'state', type: 'int', unsigned: true, default: 1, comment: '是否可用1正常2禁用3删除(不显示)' })
+  state!: number;
+
+  @Column({ name: 'is_default', type: 'int', unsigned: true, default: 0, comment: '是否常用1是 2否' })
+  isDefault!: number;
+
+  @Column({ name: 'created_by', type: 'int', unsigned: true, nullable: true, comment: '创建用户ID' })
+  createdBy!: number | null;
+
+  @Column({ name: 'updated_by', type: 'int', unsigned: true, nullable: true, comment: '更新用户ID' })
+  updatedBy!: number | null;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
+  updatedAt!: Date;
+
+  @ManyToOne(() => User)
+  @JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
+  user!: User;
+}

+ 280 - 0
src/server/modules/delivery-address/delivery-address.schema.ts

@@ -0,0 +1,280 @@
+import { z } from '@hono/zod-openapi';
+import { UserSchema } from '@/server/modules/users/user.schema';
+
+// 状态枚举
+export const DeliveryAddressStatusEnum = {
+  NORMAL: 1,
+  DISABLED: 2,
+  DELETED: 3
+} as const;
+
+export const IsDefaultEnum = {
+  NOT_DEFAULT: 0,
+  IS_DEFAULT: 1
+} as const;
+
+// 基础实体Schema
+export const DeliveryAddressSchema = z.object({
+  id: z.number()
+    .int('ID必须是整数')
+    .positive('ID必须是正整数')
+    .openapi({
+      description: '收货地址ID',
+      example: 1
+    }),
+  userId: z.number()
+    .int('用户ID必须是整数')
+    .positive('用户ID必须是正整数')
+    .openapi({
+      description: '用户ID',
+      example: 1
+    }),
+  name: z.string()
+    .min(1, '姓名不能为空')
+    .max(255, '姓名最多255个字符')
+    .openapi({
+      description: '收货人姓名',
+      example: '张三'
+    }),
+  phone: z.string()
+    .regex(/^1[3-9]\d{9}$/, '请输入正确的手机号')
+    .openapi({
+      description: '收货人手机号',
+      example: '13800138000'
+    }),
+  address: z.string()
+    .min(1, '详细地址不能为空')
+    .max(255, '详细地址最多255个字符')
+    .openapi({
+      description: '详细地址',
+      example: '北京市朝阳区建国门外大街1号'
+    }),
+  receiverProvince: z.coerce.number()
+    .int('省份ID必须是整数')
+    .positive('省份ID必须是正整数')
+    .default(0)
+    .openapi({
+      description: '收货省份ID',
+      example: 110000
+    }),
+  receiverCity: z.coerce.number()
+    .int('城市ID必须是整数')
+    .positive('城市ID必须是正整数')
+    .default(0)
+    .openapi({
+      description: '收货城市ID',
+      example: 110100
+    }),
+  receiverDistrict: z.coerce.number()
+    .int('区县ID必须是整数')
+    .positive('区县ID必须是正整数')
+    .default(0)
+    .openapi({
+      description: '收货区县ID',
+      example: 110105
+    }),
+  receiverTown: z.coerce.number()
+    .int('街道ID必须是整数')
+    .positive('街道ID必须是正整数')
+    .default(0)
+    .openapi({
+      description: '收货街道ID',
+      example: 110105001
+    }),
+  state: z.coerce.number()
+    .int('状态必须是整数')
+    .min(1, '状态最小值为1')
+    .max(3, '状态最大值为3')
+    .default(1)
+    .openapi({
+      description: '状态:1正常,2禁用,3删除',
+      example: 1
+    }),
+  isDefault: z.coerce.number()
+    .int('是否默认必须是整数')
+    .min(0, '最小值为0')
+    .max(1, '最大值为1')
+    .default(0)
+    .openapi({
+      description: '是否默认地址:0否,1是',
+      example: 0
+    }),
+  createdBy: z.number()
+    .int('创建人ID必须是整数')
+    .positive('创建人ID必须是正整数')
+    .nullable()
+    .openapi({
+      description: '创建用户ID',
+      example: 1
+    }),
+  updatedBy: z.number()
+    .int('更新人ID必须是整数')
+    .positive('更新人ID必须是正整数')
+    .nullable()
+    .openapi({
+      description: '更新用户ID',
+      example: 1
+    }),
+  createdAt: z.coerce.date('创建时间格式不正确')
+    .openapi({
+      description: '创建时间',
+      example: '2024-01-01T12:00:00Z'
+    }),
+  updatedAt: z.coerce.date('更新时间格式不正确')
+    .openapi({
+      description: '更新时间',
+      example: '2024-01-01T12:00:00Z'
+    }),
+  user: UserSchema.optional().openapi({
+    description: '关联用户信息'
+  })
+});
+
+// 创建DTO
+export const CreateDeliveryAddressDto = z.object({
+  userId: z.number()
+    .int('用户ID必须是整数')
+    .positive('用户ID必须是正整数')
+    .openapi({
+      description: '用户ID',
+      example: 1
+    }),
+  name: z.string()
+    .min(1, '收货人姓名不能为空')
+    .max(255, '收货人姓名最多255个字符')
+    .openapi({
+      description: '收货人姓名',
+      example: '张三'
+    }),
+  phone: z.string()
+    .regex(/^1[3-9]\d{9}$/, '请输入正确的手机号')
+    .openapi({
+      description: '收货人手机号',
+      example: '13800138000'
+    }),
+  address: z.string()
+    .min(1, '详细地址不能为空')
+    .max(255, '详细地址最多255个字符')
+    .openapi({
+      description: '详细地址',
+      example: '北京市朝阳区建国门外大街1号'
+    }),
+  receiverProvince: z.coerce.number()
+    .int('省份ID必须是整数')
+    .positive('省份ID必须是正整数')
+    .default(0)
+    .openapi({
+      description: '收货省份ID',
+      example: 110000
+    }),
+  receiverCity: z.coerce.number()
+    .int('城市ID必须是整数')
+    .positive('城市ID必须是正整数')
+    .default(0)
+    .openapi({
+      description: '收货城市ID',
+      example: 110100
+    }),
+  receiverDistrict: z.coerce.number()
+    .int('区县ID必须是整数')
+    .positive('区县ID必须是正整数')
+    .default(0)
+    .openapi({
+      description: '收货区县ID',
+      example: 110105
+    }),
+  receiverTown: z.coerce.number()
+    .int('街道ID必须是整数')
+    .positive('街道ID必须是正整数')
+    .default(0)
+    .openapi({
+      description: '收货街道ID',
+      example: 110105001
+    }),
+  isDefault: z.coerce.number()
+    .int('是否默认必须是整数')
+    .min(0, '最小值为0')
+    .max(1, '最大值为1')
+    .default(0)
+    .openapi({
+      description: '是否默认地址:0否,1是',
+      example: 0
+    })
+});
+
+// 更新DTO
+export const UpdateDeliveryAddressDto = z.object({
+  name: z.string()
+    .min(1, '收货人姓名不能为空')
+    .max(255, '收货人姓名最多255个字符')
+    .optional()
+    .openapi({
+      description: '收货人姓名',
+      example: '张三'
+    }),
+  phone: z.string()
+    .regex(/^1[3-9]\d{9}$/, '请输入正确的手机号')
+    .optional()
+    .openapi({
+      description: '收货人手机号',
+      example: '13800138000'
+    }),
+  address: z.string()
+    .min(1, '详细地址不能为空')
+    .max(255, '详细地址最多255个字符')
+    .optional()
+    .openapi({
+      description: '详细地址',
+      example: '北京市朝阳区建国门外大街1号'
+    }),
+  receiverProvince: z.coerce.number()
+    .int('省份ID必须是整数')
+    .positive('省份ID必须是正整数')
+    .optional()
+    .openapi({
+      description: '收货省份ID',
+      example: 110000
+    }),
+  receiverCity: z.coerce.number()
+    .int('城市ID必须是整数')
+    .positive('城市ID必须是正整数')
+    .optional()
+    .openapi({
+      description: '收货城市ID',
+      example: 110100
+    }),
+  receiverDistrict: z.coerce.number()
+    .int('区县ID必须是整数')
+    .positive('区县ID必须是正整数')
+    .optional()
+    .openapi({
+      description: '收货区县ID',
+      example: 110105
+    }),
+  receiverTown: z.coerce.number()
+    .int('街道ID必须是整数')
+    .positive('街道ID必须是正整数')
+    .optional()
+    .openapi({
+      description: '收货街道ID',
+      example: 110105001
+    }),
+  state: z.coerce.number()
+    .int('状态必须是整数')
+    .min(1, '状态最小值为1')
+    .max(3, '状态最大值为3')
+    .optional()
+    .openapi({
+      description: '状态:1正常,2禁用,3删除',
+      example: 1
+    }),
+  isDefault: z.coerce.number()
+    .int('是否默认必须是整数')
+    .min(0, '最小值为0')
+    .max(1, '最大值为1')
+    .optional()
+    .openapi({
+      description: '是否默认地址:0否,1是',
+      example: 0
+    })
+});

+ 58 - 0
src/server/modules/delivery-address/delivery-address.service.ts

@@ -0,0 +1,58 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { DeliveryAddress } from './delivery-address.entity';
+
+export class DeliveryAddressService extends GenericCrudService<DeliveryAddress> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, DeliveryAddress);
+  }
+
+  /**
+   * 获取用户的收货地址列表
+   * @param userId 用户ID
+   * @returns 收货地址列表
+   */
+  async findByUser(userId: number): Promise<DeliveryAddress[]> {
+    return this.repository.find({
+      where: { userId, state: 1 },
+      relations: ['user'],
+      order: { isDefault: 'DESC', createdAt: 'DESC' }
+    });
+  }
+
+  /**
+   * 设置默认地址
+   * @param id 地址ID
+   * @param userId 用户ID
+   * @returns 是否设置成功
+   */
+  async setDefault(id: number, userId: number): Promise<boolean> {
+    await this.repository.manager.transaction(async (transactionalEntityManager) => {
+      // 先将该用户的所有地址设为非默认
+      await transactionalEntityManager.update(DeliveryAddress, 
+        { userId }, 
+        { isDefault: 0 }
+      );
+      
+      // 将指定地址设为默认
+      await transactionalEntityManager.update(DeliveryAddress,
+        { id, userId },
+        { isDefault: 1 }
+      );
+    });
+    
+    return true;
+  }
+
+  /**
+   * 获取用户的默认地址
+   * @param userId 用户ID
+   * @returns 默认地址或null
+   */
+  async findDefaultByUser(userId: number): Promise<DeliveryAddress | null> {
+    return this.repository.findOne({
+      where: { userId, isDefault: 1, state: 1 },
+      relations: ['user']
+    });
+  }
+}

+ 139 - 0
src/server/modules/orders/order.entity.ts

@@ -0,0 +1,139 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
+import { User } from '@/server/modules/users/user.entity';
+import { Merchant } from '@/server/modules/merchant/merchant.entity';
+import { Supplier } from '@/server/modules/supplier/supplier.entity';
+import { DeliveryAddress } from '@/server/modules/delivery-address/delivery-address.entity';
+
+@Entity('orders')
+export class Order {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'order_no', type: 'varchar', length: 50, unique: true, comment: '订单号' })
+  orderNo!: string;
+
+  @Column({ name: 'user_id', type: 'int', unsigned: true, comment: '用户ID' })
+  userId!: number;
+
+  @Column({ name: 'auth_code', type: 'varchar', length: 32, nullable: true, comment: '付款码' })
+  authCode!: string | null;
+
+  @Column({ name: 'card_no', type: 'varchar', length: 32, nullable: true, comment: '卡号' })
+  cardNo!: string | null;
+
+  @Column({ name: 'sjt_card_no', type: 'varchar', length: 32, nullable: true, comment: '盛京通卡号' })
+  sjtCardNo!: string | null;
+
+  @Column({ name: 'amount', type: 'decimal', precision: 10, scale: 2, default: 0.00, comment: '订单金额' })
+  amount!: number;
+
+  @Column({ name: 'cost_amount', type: 'decimal', precision: 10, scale: 2, default: 0.00, comment: '成本金额' })
+  costAmount!: number;
+
+  @Column({ name: 'freight_amount', type: 'decimal', precision: 10, scale: 2, default: 0.00, comment: '运费' })
+  freightAmount!: number;
+
+  @Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2, default: 0.00, comment: '优惠金额' })
+  discountAmount!: number;
+
+  @Column({ name: 'pay_amount', type: 'decimal', precision: 10, scale: 2, default: 0.00, comment: '实际支付金额' })
+  payAmount!: number;
+
+  @Column({ name: 'device_no', type: 'varchar', length: 255, nullable: true, comment: '设备编号' })
+  deviceNo!: string | null;
+
+  @Column({ name: 'description', type: 'varchar', length: 255, nullable: true, comment: '订单描述' })
+  description!: string | null;
+
+  @Column({ name: 'goods_detail', type: 'varchar', length: 2000, nullable: true, comment: '订单详情 json' })
+  goodsDetail!: string | null;
+
+  @Column({ name: 'goods_tag', type: 'varchar', length: 255, nullable: true, comment: '订单优惠标记' })
+  goodsTag!: string | null;
+
+  @Column({ name: 'address', type: 'varchar', length: 255, nullable: true, comment: '地址' })
+  address!: string | null;
+
+  @Column({ name: 'order_type', type: 'int', default: 1, comment: '订单类型 1实物订单 2虚拟订单' })
+  orderType!: number;
+
+  @Column({ name: 'pay_type', type: 'int', default: 0, comment: '支付类型1积分2礼券' })
+  payType!: number;
+
+  @Column({ name: 'pay_state', type: 'int', default: 0, comment: '0未支付1支付中2支付成功3已退款4支付失败5订单关闭' })
+  payState!: number;
+
+  @Column({ name: 'state', type: 'int', default: 0, comment: '订单状态0未发货1已发货2收货成功3已退货' })
+  state!: number;
+
+  @Column({ name: 'user_phone', type: 'varchar', length: 50, nullable: true, comment: '用户手机号' })
+  userPhone!: string | null;
+
+  @Column({ name: 'merchant_id', type: 'int', unsigned: true, default: 0, comment: '商户id' })
+  merchantId!: number;
+
+  @Column({ name: 'merchant_no', type: 'int', unsigned: true, nullable: true, comment: '商户号' })
+  merchantNo!: number | null;
+
+  @Column({ name: 'supplier_id', type: 'int', unsigned: true, default: 0, comment: '供货商id' })
+  supplierId!: number;
+
+  @Column({ name: 'address_id', type: 'int', unsigned: true, default: 0, comment: '地址id' })
+  addressId!: number;
+
+  @Column({ name: 'receiver_mobile', type: 'varchar', length: 255, nullable: true, comment: '收货人手机号' })
+  receiverMobile!: string | null;
+
+  @Column({ name: 'recevier_name', type: 'varchar', length: 255, nullable: true, comment: '收货人姓名' })
+  recevierName!: string | null;
+
+  @Column({ name: 'recevier_province', type: 'bigint', default: 0, comment: '收货人所在省' })
+  recevierProvince!: number;
+
+  @Column({ name: 'recevier_city', type: 'bigint', default: 0, comment: '收货人所在市' })
+  recevierCity!: number;
+
+  @Column({ name: 'recevier_district', type: 'bigint', default: 0, comment: '收货人所在区' })
+  recevierDistrict!: number;
+
+  @Column({ name: 'recevier_town', type: 'bigint', default: 0, comment: '收货人所在街道' })
+  recevierTown!: number;
+
+  @Column({ name: 'refund_time', type: 'timestamp', nullable: true, comment: '退款时间' })
+  refundTime!: Date | null;
+
+  @Column({ name: 'close_time', type: 'timestamp', nullable: true, comment: '订单关闭时间' })
+  closeTime!: Date | null;
+
+  @Column({ name: 'remark', type: 'varchar', length: 255, nullable: true, comment: '管理员备注信息' })
+  remark!: string | null;
+
+  @Column({ name: 'created_by', type: 'int', unsigned: true, nullable: true, comment: '创建人ID' })
+  createdBy!: number | null;
+
+  @Column({ name: 'updated_by', type: 'int', unsigned: true, nullable: true, comment: '更新人ID' })
+  updatedBy!: number | null;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
+  updatedAt!: Date;
+
+  // 关联关系
+  @ManyToOne(() => User)
+  @JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
+  user!: User;
+
+  @ManyToOne(() => Merchant)
+  @JoinColumn({ name: 'merchant_id', referencedColumnName: 'id' })
+  merchant!: Merchant;
+
+  @ManyToOne(() => Supplier)
+  @JoinColumn({ name: 'supplier_id', referencedColumnName: 'id' })
+  supplier!: Supplier;
+
+  @ManyToOne(() => DeliveryAddress)
+  @JoinColumn({ name: 'address_id', referencedColumnName: 'id' })
+  deliveryAddress!: DeliveryAddress;
+}