| null | un
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId),
+ phone: String(raw.phone ?? base.phone),
+ contactPhone:
+ String(raw.contactPhone ?? '').trim() || String(raw.phone ?? '').trim() || base.contactPhone,
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
openTime: String(raw.openTime ?? base.openTime),
@@ -236,14 +243,16 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
export function validateStoreStep3(
form: Pick<
StoreDraftForm,
- 'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
+ 'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'contactPhone'
>,
): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行';
- if (!form.phone.trim()) return '请填写联系电话';
- if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
+ if (!form.phone.trim()) return '请填写门店登录手机号';
+ if (!PHONE_RE.test(form.phone.trim())) return '门店登录手机号须为11位手机号';
+ if (!form.contactPhone.trim()) return '请填写联系电话';
+ if (!PHONE_RE.test(form.contactPhone.trim())) return '联系电话须为11位手机号';
return null;
}
diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx
index 280c311..940a03b 100644
--- a/apps/h5-partner/src/pages/StoreCreatePage.tsx
+++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx
@@ -56,6 +56,7 @@ type StoreCategoryNode = {
type FieldErrors = {
phone?: string;
+ contactPhone?: string;
};
@@ -237,9 +238,17 @@ export default function StoreCreatePage() {
setSubmitError('');
- if ('phone' in patch) {
+ if ('phone' in patch || 'contactPhone' in patch) {
- setFieldErrors((prev) => ({ ...prev, phone: undefined }));
+ setFieldErrors((prev) => ({
+
+ ...prev,
+
+ ...('phone' in patch ? { phone: undefined } : {}),
+
+ ...('contactPhone' in patch ? { contactPhone: undefined } : {}),
+
+ }));
}
@@ -315,7 +324,9 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
- setFieldErrors({ phone: msg });
+ setFieldErrors(
+ msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
+ );
reportStepError(msg);
return;
}
@@ -336,7 +347,9 @@ export default function StoreCreatePage() {
if (msg) {
if (isPhoneValidationMessage(msg)) {
- setFieldErrors({ phone: msg });
+ setFieldErrors(
+ msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
+ );
return;
}
reportFormError(msg);
@@ -441,6 +454,8 @@ export default function StoreCreatePage() {
phone: form.phone.trim(),
+ contactPhone: form.contactPhone.trim() || form.phone.trim(),
+
district: form.district.trim(),
address: form.address.trim(),
@@ -1089,7 +1104,43 @@ export default function StoreCreatePage() {
- 该手机号将作为门店端登录账号。
+ 老板手机号,作为门店端主账号登录凭证。
+
+
+
+
+
+
+
+
+
+
+
+ phone_in_talk
+
+ patchForm({ contactPhone: e.target.value })}
+
+ />
+
+
+
+ {fieldErrors.contactPhone && (
+
+
{fieldErrors.contactPhone}
+
+ )}
+
+
+
+ 用户端门店详情展示与拨号使用此号码,可与登录号不同。
diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx
index e3fe98d..a7b7059 100644
--- a/apps/h5-partner/src/pages/StoreDetailPage.tsx
+++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx
@@ -43,7 +43,8 @@ export default function StoreDetailPage() {
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({
name: '',
- phone: '',
+ loginPhone: '',
+ contactPhone: '',
address: '',
intro: '',
benefitUsageRule: '',
@@ -64,7 +65,8 @@ export default function StoreDetailPage() {
setStore(data);
setForm({
name: String(data.name || ''),
- phone: String(data.phone || ''),
+ loginPhone: String(data.phone || ''),
+ contactPhone: String(data.contactPhone || data.phone || ''),
address: String(data.address || ''),
intro: String(data.intro || ''),
benefitUsageRule: (() => {
@@ -164,7 +166,7 @@ export default function StoreDetailPage() {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
- phone: form.phone.trim(),
+ contactPhone: form.contactPhone.trim(),
address: form.address.trim(),
intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
@@ -346,12 +348,30 @@ export default function StoreDetailPage() {
setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
+
+
+
+ lock
+
+
+
+ 老板主账号,用于门店端登录;如需变更请联系总部。
+
+
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 7f49349..505a7f3 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -1248,7 +1248,10 @@ model Store {
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
name String @db.VarChar(128)
+ /// 门店主账号登录手机号(老板);与 StoreAccount.phone 对齐
phone String @db.VarChar(20)
+ /// 对外联系电话(店长等);C 端拨号展示用;空则回退 phone
+ contactPhone String? @map("contact_phone") @db.VarChar(20)
province String @db.VarChar(32)
cityName String @map("city_name") @db.VarChar(32)
district String @db.VarChar(32)
diff --git a/server/dukang-api/src/common/compat/v31-compat.ts b/server/dukang-api/src/common/compat/v31-compat.ts
index 4ca26ab..ec7c7fb 100644
--- a/server/dukang-api/src/common/compat/v31-compat.ts
+++ b/server/dukang-api/src/common/compat/v31-compat.ts
@@ -44,11 +44,36 @@ export function mapOrderCompat(store: T) {
- return {
+/** 对外联系电话;未单独配置时回退登录手机号 */
+export function resolveStoreContactPhone(store: {
+ phone?: string | null;
+ contactPhone?: string | null;
+}): string {
+ const contact = String(store.contactPhone ?? '').trim();
+ if (contact) return contact;
+ return String(store.phone ?? '').trim();
+}
+
+export function mapStoreCompat<
+ T extends {
+ coverResource?: { url?: string } | null;
+ phone?: string | null;
+ contactPhone?: string | null;
+ },
+>(store: T, opts?: { /** C 端:phone 字段对外可拨打号码 */ publicDial?: boolean }) {
+ const contactPhone = resolveStoreContactPhone(store) || null;
+ const mapped = {
...store,
coverUrl: store.coverResource?.url ?? null,
+ contactPhone,
};
+ if (opts?.publicDial) {
+ return {
+ ...mapped,
+ phone: contactPhone || store.phone || null,
+ };
+ }
+ return mapped;
}
export function mapStatusLogCompat(events: CommonEvent[]) {
diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts
index 0f7351e..d3d066e 100644
--- a/server/dukang-api/src/modules/ops/admin-stores.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts
@@ -146,8 +146,10 @@ export class AdminStoresService {
visibilityPhones: visibilityPhones.map((p) => p.phone),
partner: store.partnerAccount,
account: store.bindings[0]?.storeAccount ?? null,
- /** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
+ /** 门店端登录手机号(store_account.phone);与 store.phone 对齐 */
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
+ /** 对外联系电话;空则回退登录号 */
+ contactPhone: store.contactPhone?.trim() || store.phone,
bindings: undefined,
media,
audits,
@@ -275,11 +277,19 @@ export class AdminStoresService {
if (dto.phone !== undefined) {
const normalizedPhone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
- throw new BadRequestException('请输入正确的手机号码');
+ throw new BadRequestException('请输入正确的登录手机号');
+ }
+ }
+ if (dto.contactPhone !== undefined) {
+ const contact = dto.contactPhone.trim();
+ if (contact && !/^1[3-9]\d{9}$/.test(contact)) {
+ throw new BadRequestException('请输入正确的联系电话');
}
}
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
+ const normalizedContactPhone =
+ dto.contactPhone !== undefined ? dto.contactPhone.trim() || null : undefined;
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
const nextEnabled =
dto.visibilityWhitelistEnabled !== undefined
@@ -296,7 +306,7 @@ export class AdminStoresService {
const needAccountSync =
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
- // 登录凭证在 store_account.phone;必须与门店展示手机号同步
+ // 登录凭证在 store_account.phone;与 store.phone(老板登录号)同步,不与对外联系电话绑定
let primaryBinding: {
storeAccount: { id: bigint; phone: string; name: string } | null;
} | null = null;
@@ -346,6 +356,9 @@ export class AdminStoresService {
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
+ ...(normalizedContactPhone !== undefined
+ ? { contactPhone: normalizedContactPhone }
+ : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.benefitUsageRule !== undefined
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
@@ -562,6 +575,11 @@ export class AdminStoresService {
? !!dto.isTest
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
+ const contactPhoneRaw = dto.contactPhone?.trim() || normalizedPhone;
+ if (!/^1[3-9]\d{9}$/.test(contactPhoneRaw)) {
+ throw new BadRequestException('请输入正确的联系电话');
+ }
+
const store = await this.prisma.store.create({
data: {
cityId: city.id,
@@ -570,6 +588,7 @@ export class AdminStoresService {
categoryId,
name: dto.name,
phone: normalizedPhone,
+ contactPhone: contactPhoneRaw,
province: dto.province ?? city.province,
cityName: dto.city ?? city.name,
district: dto.district ?? '',
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 9174629..2ab73f9 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
@@ -38,6 +38,11 @@ export class CreateStoreDto {
@IsNotEmpty()
phone: string;
+ /** 对外联系电话(店长);不传则默认与登录手机号相同 */
+ @IsOptional()
+ @IsString()
+ contactPhone?: string;
+
@IsString()
@IsNotEmpty()
categoryId: string;
@@ -162,6 +167,11 @@ export class UpdateStoreDto {
@IsString()
phone?: string;
+ /** 对外联系电话(店长) */
+ @IsOptional()
+ @IsString()
+ contactPhone?: string;
+
@IsOptional()
@IsString()
intro?: string;
diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts
index a5036db..e6511b4 100644
--- a/server/dukang-api/src/modules/store/store.service.ts
+++ b/server/dukang-api/src/modules/store/store.service.ts
@@ -179,11 +179,14 @@ export class StoreService {
for (const store of visible) {
const coords = await this.ensureStoreCoordinates(store);
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
- const mapped = mapStoreCompat({
- ...rest,
- latitude: coords?.latitude ?? store.latitude,
- longitude: coords?.longitude ?? store.longitude,
- });
+ const mapped = mapStoreCompat(
+ {
+ ...rest,
+ latitude: coords?.latitude ?? store.latitude,
+ longitude: coords?.longitude ?? store.longitude,
+ },
+ { publicDial: true },
+ );
const distanceMeters =
hasUser && coords
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
@@ -230,29 +233,32 @@ export class StoreService {
});
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
- mapStoreCompat({
- ...rest,
- latitude: coords?.latitude ?? store.latitude,
- longitude: coords?.longitude ?? store.longitude,
- media,
- packages: packageRows.map((p) => {
- const imageUrls = normalizeStorePackageImageUrls({
- imageUrl: p.imageUrl,
- imageUrls: p.imageUrls,
- });
- return {
- name: p.name,
- price: p.price.toFixed(2),
- dishes: p.dishes,
- usableTime: p.usableTime,
- otherNotes: p.otherNotes,
- imageUrl: imageUrls[0] ?? null,
- /** C 端始终返回数组,便于多图轮播 */
- imageUrls,
- sortOrder: p.sortOrder,
- };
- }),
- }),
+ mapStoreCompat(
+ {
+ ...rest,
+ latitude: coords?.latitude ?? store.latitude,
+ longitude: coords?.longitude ?? store.longitude,
+ media,
+ packages: packageRows.map((p) => {
+ const imageUrls = normalizeStorePackageImageUrls({
+ imageUrl: p.imageUrl,
+ imageUrls: p.imageUrls,
+ });
+ return {
+ name: p.name,
+ price: p.price.toFixed(2),
+ dishes: p.dishes,
+ usableTime: p.usableTime,
+ otherNotes: p.otherNotes,
+ imageUrl: imageUrls[0] ?? null,
+ /** C 端始终返回数组,便于多图轮播 */
+ imageUrls,
+ sortOrder: p.sortOrder,
+ };
+ }),
+ },
+ { publicDial: true },
+ ),
);
}
@@ -275,7 +281,7 @@ export class StoreService {
include: { category: true, coverResource: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
- return serializeBigInt(stores.map(mapStoreCompat));
+ return serializeBigInt(stores.map((s) => mapStoreCompat(s)));
}
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
@@ -307,10 +313,10 @@ export class StoreService {
async partnerCheckStorePhone(phone: string) {
const normalizedPhone = phone.trim();
if (!normalizedPhone) {
- return { available: false, message: '请填写联系电话' };
+ return { available: false, message: '请填写门店登录手机号' };
}
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
- return { available: false, message: '联系电话须为11位手机号' };
+ return { available: false, message: '门店登录手机号须为11位手机号' };
}
const existingAccount = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
@@ -360,6 +366,14 @@ export class StoreService {
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
+ const contactPhoneRaw =
+ body.contactPhone != null && String(body.contactPhone).trim()
+ ? String(body.contactPhone).trim()
+ : normalizedPhone;
+ if (!/^1[3-9]\d{9}$/.test(contactPhoneRaw)) {
+ throw new BadRequestException('联系电话须为11位手机号');
+ }
+
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
@@ -416,6 +430,7 @@ export class StoreService {
categoryId,
name: String(body.name),
phone: normalizedPhone,
+ contactPhone: contactPhoneRaw,
province: String(body.province ?? city.province ?? '河南省'),
cityName: String(body.city ?? city.name ?? '郑州市'),
district: String(body.district ?? ''),
@@ -614,7 +629,13 @@ export class StoreService {
}
const name = body.name !== undefined ? String(body.name).trim() : undefined;
- const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
+ // 合伙人资料修改:联系电话 → contactPhone(兼容旧客户端把 phone 当联系电话提交)
+ const contactPhone =
+ body.contactPhone !== undefined
+ ? String(body.contactPhone).trim()
+ : body.phone !== undefined
+ ? String(body.phone).trim()
+ : undefined;
const address = body.address !== undefined ? String(body.address).trim() : undefined;
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
const benefitUsageRuleRaw =
@@ -627,7 +648,7 @@ export class StoreService {
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
- if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
+ if (contactPhone !== undefined && !/^1[3-9]\d{9}$/.test(contactPhone)) {
throw new BadRequestException('联系电话须为11位手机号');
}
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
@@ -650,7 +671,7 @@ export class StoreService {
where: { id: storeId },
data: {
...(name !== undefined ? { name } : {}),
- ...(phone !== undefined ? { phone } : {}),
+ ...(contactPhone !== undefined ? { contactPhone } : {}),
...(address !== undefined
? hasCoordsUpdate
? { address }
diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md
index 0040fe6..2f72baa 100644
--- a/杜康好客-v3-PRD.md
+++ b/杜康好客-v3-PRD.md
@@ -21,6 +21,7 @@
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
+| 3.4.16 | 08-09 | 门店登录手机号与对外联系电话分离 | 见 §3.12 |
---
@@ -93,6 +94,17 @@
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
+| 3.12 | **登录手机号 ≠ 对外联系电话** | 见下 |
+
+### 3.12 门店登录号与对外联系电话
+
+| 字段 | 含义 | 用途 |
+|------|------|------|
+| `phone` / 门店登录手机号 | 老板主账号 | 门店端短信登录;`StoreAccount.phone` |
+| `contactPhone` / 联系电话 | 店长等对外号码 | C 端门店详情拨号与展示 |
+
+- 合伙人拓店入驻、门店资料修改均须采集 **联系电话**(可与登录号不同)。
+- 未填联系电话时,对外展示回退登录手机号(兼容旧数据)。
---
diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md
index 001b030..0708380 100644
--- a/杜康好客-v3-现状对照.md
+++ b/杜康好客-v3-现状对照.md
@@ -1,4 +1,4 @@
-# 杜康好客 · V3.0 现状对照
+# 杜康好客 · V3.0 现状对照
> 基准:仅 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · 总部 = **`admin-web`**(非 H5)
> 图例:✅ 完成 · 🔶 部分 · ❌ 未做 · ⚠️ 曾冲突已修
@@ -9,7 +9,7 @@
|------|------|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
-| 近期版本 | v3.4.10 套餐 · v3.4.11 开发计划/企微 · v3.4.12 工单迭代 · v3.4.13 体验优化 **已发生产** |
+| 近期版本 | … · v3.4.15 门店列表/套餐多图 · **v3.4.16 登录号/联系电话分离(开发中)** |
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
@@ -55,17 +55,16 @@
| 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ |
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
-| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
-| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
+| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
+| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
-## 5. 变更记录
+| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态
| 日期 | 说明 |
|------|------|
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
| 2026-08-07 | v3.4.15 增补:HQ 门店照片替换/删除、套餐多图上限 20 |
-| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
-| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
+
| 2026-08-05 | v3.4.13 |
| 2026-08-04 | v3.4.11 / v3.4.12 |
| 2026-07-11 | 首版对照表 |