dfc-customer-sync 3.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ """dfc-customer-sync - 巨懂车客户自动同步到大风车 CRM。"""
2
+
3
+ __version__ = "3.6.0"
4
+ __author__ = "damienchen"
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env python3
2
+ """dfc-customer-sync 包入口。
3
+
4
+ 用法:
5
+ python -m dfc_customer_sync --setup
6
+ python -m dfc_customer_sync
7
+ python -m dfc_customer_sync --status
8
+ """
9
+
10
+ from . import sync_daemon
11
+
12
+ if __name__ == "__main__":
13
+ sync_daemon.main()
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 大风车认证模块 — APP_KEY 方式
4
+ 通过大风车开放平台 API 获取 Token
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import ssl
10
+ import sys
11
+ import urllib.request
12
+ import urllib.error
13
+ from typing import Dict, Optional
14
+
15
+
16
+ # 大风车 Token API
17
+ TOKEN_URL = "https://ai-assistant-web.souche.com/openclaw/token/getByAppKey"
18
+ ACCOUNT_URL = "https://danube-tenant-web.souche.com/account/info.json"
19
+ SHOP_LIST_URL = "https://danube-tenant-web.souche.com/shops/list.json"
20
+ API_BASE = "https://crazyracing-kartrider.souche.com"
21
+
22
+ # 标准请求头
23
+ COMMON_HEADERS = {
24
+ "Content-Type": "application/json;charset=UTF-8",
25
+ "Accept": "application/json, text/plain, */*",
26
+ "User-Agent": "Mozilla/5.0 (Linux; Android 12; ALN-AL00) AppleWebKit/537.36",
27
+ "Origin": "https://xindafengche.souche.com",
28
+ "Referer": "https://xindafengche.souche.com/",
29
+ "_source_code": "WEB",
30
+ }
31
+
32
+
33
+ def _ssl_context():
34
+ """创建 SSL 上下文(禁用证书验证)"""
35
+ ctx = ssl.create_default_context()
36
+ ctx.check_hostname = False
37
+ ctx.verify_mode = ssl.CERT_NONE
38
+ return ctx
39
+
40
+
41
+ def get_token(env_name: str = "APP_KEY") -> str:
42
+ """
43
+ 通过 APP_KEY 获取 Token
44
+
45
+ Returns:
46
+ dfcToken 字符串
47
+
48
+ Raises:
49
+ Exception: APP_KEY 未设置或获取失败时抛出
50
+ """
51
+ app_key = os.getenv(env_name)
52
+ if not app_key:
53
+ raise Exception(
54
+ f"❌ 未找到环境变量 {env_name}\n"
55
+ f"请检查服务器是否已配置 {env_name}"
56
+ )
57
+
58
+ url = f"{TOKEN_URL}?appKey={app_key}"
59
+ req = urllib.request.Request(url, headers=COMMON_HEADERS, method="GET")
60
+
61
+ try:
62
+ with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
63
+ data = json.loads(resp.read().decode("utf-8"))
64
+ except urllib.error.URLError as e:
65
+ raise Exception(f"获取 Token 失败: {e.reason}")
66
+
67
+ if data.get("code") != "200":
68
+ raise Exception(f"Token 获取失败: {data.get('msg', '未知错误')}")
69
+
70
+ token = data.get("data", {}).get("dfcToken")
71
+ if not token:
72
+ raise Exception("Token 获取失败: 响应中无 dfcToken")
73
+
74
+ return token
75
+
76
+
77
+ def get_account_info(dfc_token: str) -> dict:
78
+ """
79
+ 通过 dfcToken 获取账户信息(orgId, shopCode, shopName)
80
+ """
81
+ headers = COMMON_HEADERS.copy()
82
+ headers["Souche-Security-Token"] = dfc_token
83
+
84
+ req = urllib.request.Request(ACCOUNT_URL, headers=headers, method="GET")
85
+
86
+ try:
87
+ with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
88
+ data = json.loads(resp.read().decode("utf-8"))
89
+ except urllib.error.URLError as e:
90
+ raise Exception(f"获取账户信息失败: {e.reason}")
91
+
92
+ info = data.get("data", {})
93
+ return {
94
+ "orgId": info.get("orgId"),
95
+ "shopCode": info.get("shopCode"),
96
+ "shopName": info.get("shopName"),
97
+ }
98
+
99
+
100
+ def list_shops(dfc_token: str) -> list:
101
+ """获取门店列表"""
102
+ headers = COMMON_HEADERS.copy()
103
+ headers["Souche-Security-Token"] = dfc_token
104
+ req = urllib.request.Request(SHOP_LIST_URL, headers=headers, method="GET")
105
+ try:
106
+ with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
107
+ data = json.loads(resp.read().decode("utf-8"))
108
+ except urllib.error.URLError as e:
109
+ raise Exception(f"获取门店列表失败: {e.reason}")
110
+ if data.get("code") != "200":
111
+ raise Exception(f"获取门店列表失败: {data.get('msg', '未知错误')}")
112
+ items = (data.get("data") or {}).get("items", [])
113
+ return [{"shopCode": s.get("code"), "shopName": s.get("name")} for s in items if s.get("code")]
114
+
115
+
116
+ def resolve_shop_scope(
117
+ dfc_token: str,
118
+ scope: str = "current",
119
+ explicit_shop_codes: Optional[list] = None,
120
+ ) -> tuple:
121
+ """解析门店范围"""
122
+ account = get_account_info(dfc_token)
123
+ if scope == "current":
124
+ return [account["shopCode"]], f"当前门店({account['shopName']})"
125
+ if scope == "all":
126
+ shops = list_shops(dfc_token)
127
+ codes = [s.get("shopCode") for s in shops if s.get("shopCode")]
128
+ if not codes:
129
+ return [account["shopCode"]], f"当前门店({account['shopName']})"
130
+ return codes, f"全部门店(共 {len(codes)} 家)"
131
+ codes = [c for c in (explicit_shop_codes or []) if c]
132
+ if not codes:
133
+ codes = [account["shopCode"]]
134
+ return codes, f"指定门店(共 {len(codes)} 家)"
135
+
136
+
137
+ def build_headers(dfc_token: str, extra: Optional[dict] = None) -> dict:
138
+ """构建业务 API 请求头"""
139
+ headers = COMMON_HEADERS.copy()
140
+ headers["Souche-Security-Token"] = dfc_token
141
+ headers["Host"] = "crazyracing-kartrider.souche.com"
142
+ headers["X-Souche-Servicechain"] = os.getenv("SOUCHE_SERVICECHAIN", "env-1025647")
143
+ if extra:
144
+ headers.update(extra)
145
+ return headers
146
+
147
+
148
+ def api_post(endpoint: str, payload: dict, dfc_token: str, extra_headers: Optional[dict] = None, silent: bool = False) -> dict:
149
+ """
150
+ 发起 POST 请求
151
+ """
152
+ url = f"{API_BASE}{endpoint}"
153
+ headers = build_headers(dfc_token, extra_headers)
154
+
155
+ data = json.dumps(payload).encode("utf-8")
156
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
157
+
158
+ try:
159
+ with urllib.request.urlopen(req, timeout=15, context=_ssl_context()) as resp:
160
+ result = json.loads(resp.read().decode("utf-8"))
161
+ except urllib.error.HTTPError as e:
162
+ if not silent:
163
+ print(f"HTTP 错误 {e.code}: {e.reason}", file=sys.stderr)
164
+ sys.exit(1)
165
+ except urllib.error.URLError as e:
166
+ if not silent:
167
+ print(f"网络错误: {e.reason}", file=sys.stderr)
168
+ sys.exit(1)
169
+
170
+ if result.get("code") != "200":
171
+ if not silent:
172
+ print(f"API 业务错误: {result.get('msg', '未知错误')}", file=sys.stderr)
173
+ sys.exit(1)
174
+
175
+ return result.get("data", {})
176
+
177
+
178
+ def get_servicechain() -> str:
179
+ """获取服务链标识"""
180
+ return os.getenv("SOUCHE_SERVICECHAIN", "env-1025647")
181
+
182
+
183
+ def get_auth_headers() -> Dict[str, str]:
184
+ """获取认证 Headers"""
185
+ return {
186
+ "_source_code": "WEB",
187
+ "content-type": "application/json;charset=UTF-8",
188
+ "souche-security-token": get_token(),
189
+ "x-souche-servicechain": get_servicechain(),
190
+ }
191
+
192
+
193
+ if __name__ == "__main__":
194
+ try:
195
+ print("🔑 正在通过 APP_KEY 获取 Token...")
196
+ token = get_token()
197
+ print(f"✅ Token 获取成功: {token[:30]}...")
198
+ print(f" 服务链: {get_servicechain()}")
199
+ except Exception as e:
200
+ print(f"{e}", file=sys.stderr)
201
+ print("\n💡 提示: 在服务器上 APP_KEY 已配置,无需手动设置", file=sys.stderr)
202
+ sys.exit(1)
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env python3
2
+ """大风车 CRM 客户读写客户端。
3
+
4
+ 统一处理所有大风车 CRM 操作:
5
+ - 读操作(查重):POST danube-chord queryFindViewRecordPageInfo.json
6
+ - 写操作(新增):POST super-mario saveCustomer.json
7
+
8
+ 认证方式:APP_KEY → Token(Souche-Security-Token header)
9
+ """
10
+
11
+ import json
12
+ import ssl
13
+ import urllib.request
14
+ import urllib.error
15
+ from typing import Dict, List, Optional
16
+
17
+ import mapping
18
+
19
+
20
+ # ============================================================
21
+ # API 端点
22
+ # ============================================================
23
+ QUERY_URL = "https://danube-chord.souche.com/generic/genericObjectAction/queryFindViewRecordPageInfo.json"
24
+ SAVE_CUSTOMER_URL = "https://super-mario.souche.com/pcbotwall/crm/customerObjectAction/saveCustomer.json"
25
+
26
+ COMMON_HEADERS = {
27
+ "Accept": "application/json, text/plain, */*",
28
+ "Accept-Language": "zh-CN,zh;q=0.9",
29
+ "Content-Type": "application/json;charset=UTF-8",
30
+ "Origin": "https://xindafengche.souche.com",
31
+ "Referer": "https://xindafengche.souche.com/",
32
+ "_source_code": "WEB",
33
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
34
+ }
35
+
36
+
37
+ # ============================================================
38
+ # 异常
39
+ # ============================================================
40
+ class DfcApiError(Exception):
41
+ """大风车 API 异常。"""
42
+ def __init__(self, message: str, kind: str = "unknown", status: Optional[int] = None):
43
+ super().__init__(message)
44
+ self.message = message
45
+ self.kind = kind
46
+ self.status = status
47
+
48
+ def to_dict(self) -> Dict:
49
+ return {"kind": self.kind, "message": self.message, "status": self.status}
50
+
51
+
52
+ def _classify_business_error(msg: str) -> str:
53
+ """根据错误信息分类异常类型。"""
54
+ text = (msg or "").lower()
55
+ if any(key in text for key in ["token", "登录", "auth", "expired", "invalid token"]):
56
+ return "auth"
57
+ if any(key in text for key in ["必填", "参数", "格式", "校验", "validate"]):
58
+ return "validation"
59
+ if any(key in text for key in ["重复", "已存在", "duplicate", "exists"]):
60
+ return "duplicate"
61
+ return "business"
62
+
63
+
64
+ # ============================================================
65
+ # SSL & HTTP
66
+ # ============================================================
67
+ def _ssl_context():
68
+ ctx = ssl.create_default_context()
69
+ ctx.check_hostname = False
70
+ ctx.verify_mode = ssl.CERT_NONE
71
+ return ctx
72
+
73
+
74
+ def _http_post(url: str, payload: Dict, token: str) -> Dict:
75
+ """发 POST,返回完整响应;业务失败抛 DfcApiError。"""
76
+ headers = COMMON_HEADERS.copy()
77
+ headers["Souche-Security-Token"] = token
78
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
79
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
80
+ try:
81
+ with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as resp:
82
+ result = json.loads(resp.read().decode("utf-8"))
83
+ except urllib.error.HTTPError as e:
84
+ if e.code in (401, 403):
85
+ raise DfcApiError(f"HTTP {e.code}: {e.reason}", kind="auth", status=e.code)
86
+ raise DfcApiError(f"HTTP {e.code}: {e.reason}", kind="http", status=e.code)
87
+ except urllib.error.URLError as e:
88
+ raise DfcApiError(f"网络错误: {e}", kind="network")
89
+ if result.get("code") != "200":
90
+ msg = result.get("msg", "未知错误")
91
+ raise DfcApiError(msg, kind=_classify_business_error(msg))
92
+ return result
93
+
94
+
95
+ # ============================================================
96
+ # 字段构造器(精简版,只保留同步流程中实际使用的字段)
97
+ # ============================================================
98
+ def _base(code, name, display_type, input_type="default", required=False, max_length=None,
99
+ match_case=None, mounted=True, display=True):
100
+ f = {
101
+ "builtIn": True, "canRead": True, "code": code,
102
+ "display": display, "displayType": display_type,
103
+ "editable": True, "inputType": input_type,
104
+ "name": name, "required": required, "type": "TEXT",
105
+ "mounted": mounted, "key": code, "validate": True, "validateMsg": "",
106
+ }
107
+ if max_length is not None:
108
+ f["maxLength"] = max_length
109
+ if match_case is not None:
110
+ f["matchCase"] = match_case
111
+ return f
112
+
113
+
114
+ def _text_field(code, name, content, max_length=255, match_case=False, display_type="short"):
115
+ f = _base(code, name, display_type, match_case=match_case, max_length=max_length)
116
+ f["type"] = "TEXT"
117
+ f["patterns"] = []
118
+ if content is not None:
119
+ f["content"] = content
120
+ return f
121
+
122
+
123
+ def _radio_field(code, name, code_chosen, elements_dict, display_type="radioButton"):
124
+ elements = [{"code": v, "name": k} for k, v in elements_dict.items()]
125
+ f = _base(code, name, display_type)
126
+ f["type"] = "RADIO"
127
+ f["codeChosen"] = code_chosen
128
+ f["elements"] = elements
129
+ return f
130
+
131
+
132
+ def _lookup_field(code, name, record_id, record_display, obj_code, target_obj_code,
133
+ required=False):
134
+ f = _base(code, name, "default", required=required)
135
+ f["type"] = "LOOKUP"
136
+ f["objCode"] = obj_code
137
+ f["targetObjCode"] = target_obj_code
138
+ f["recordId"] = record_id
139
+ f["recordDisplay"] = record_display
140
+ return f
141
+
142
+
143
+ def _create_type_field():
144
+ """客户创建方式(必填),默认 PC-主动创建。"""
145
+ elements = [
146
+ {"code": "TfAFSLA4ES_option_value_CmAXtBu0M9", "name": "线索创建"},
147
+ {"code": "TfAFSLA4ES_option_value_zhwlM7FhH5", "name": "APP-主动创建"},
148
+ {"code": "TfAFSLA4ES_option_value_ZFA7I3dLUf", "name": "销售订单创建"},
149
+ {"code": "TfAFSLA4ES_option_value_swsV7sYozR", "name": "PC-主动创建"},
150
+ {"code": "TfAFSLA4ES_option_value_vKOkQdCzab", "name": "批量导入"},
151
+ {"code": "TfAFSLA4ES_option_value_tsK42eGzMd", "name": "通讯录导入"},
152
+ {"code": "TfAFSLA4ES_option_value_nksb0DdY6C", "name": "采购订单创建"},
153
+ {"code": "tA6f2zuK8A", "name": "企微销售助手创建"},
154
+ ]
155
+ f = _base("customer_field_create_type", "客户创建方式", "picker")
156
+ f["type"] = "RADIO"
157
+ f["required"] = True
158
+ f["codeChosen"] = "TfAFSLA4ES_option_value_swsV7sYozR" # PC-主动创建
159
+ f["elements"] = elements
160
+ return f
161
+
162
+
163
+ # ============================================================
164
+ # 构建 fields 数组(精简版,只保留同步流程中实际使用的字段)
165
+ # ============================================================
166
+ def build_fields(data: dict) -> list:
167
+ """
168
+ 输入简化数据,返回 CRM 接口需要的完整 fields 数组。
169
+
170
+ 实际使用的字段:
171
+ phone: 手机号(必填)
172
+ shop: {recordId, recordDisplay} 门店(必填)
173
+ name, source(中文名), gender(中文名), grade(H/A/B/C/N),
174
+ is_important(是/否), owner, intent, wechat
175
+ """
176
+ fields = []
177
+
178
+ # 门店(必填)
179
+ shop = data.get("shop")
180
+ if shop:
181
+ fields.append(_lookup_field("customer_field_shop_code", "门店",
182
+ shop["recordId"], shop["recordDisplay"],
183
+ "scdo_shop", "scdo_shop", required=True))
184
+
185
+ # 销售
186
+ owner = data.get("owner")
187
+ if owner:
188
+ fields.append(_lookup_field("customer_field_owner", "销售",
189
+ owner["recordId"], owner["recordDisplay"],
190
+ "scdo_user", "scdo_user"))
191
+
192
+ # 手机号(必填)
193
+ if "phone" in data:
194
+ fields.append(_text_field("customer_field_phone", "手机号",
195
+ data["phone"], max_length=11, match_case=False))
196
+
197
+ # 姓名
198
+ if "name" in data and data["name"]:
199
+ fields.append(_text_field("customer_field_name", "姓名",
200
+ data["name"], max_length=255, match_case=True))
201
+
202
+ # 微信号
203
+ if "wechat" in data and data["wechat"]:
204
+ fields.append(_text_field("customer_field_weichat", "微信号",
205
+ data["wechat"], max_length=20, match_case=False))
206
+
207
+ # 性别
208
+ if "gender" in data:
209
+ gender_name = data["gender"]
210
+ code = mapping.GENDER.get(gender_name, mapping.GENDER["未知"])
211
+ fields.append(_radio_field("customer_field_gender", "性别", code, mapping.GENDER))
212
+
213
+ # 客户来源
214
+ if "source" in data:
215
+ source_name = data["source"]
216
+ code = mapping.SOURCE.get(source_name, mapping.SOURCE["其他"])
217
+ fields.append(_radio_field("customer_field_source", "客户来源", code, mapping.SOURCE, "picker"))
218
+
219
+ # 意向等级
220
+ if "grade" in data:
221
+ grade_name = data["grade"]
222
+ code = mapping.GRADE.get(grade_name, mapping.GRADE["C"])
223
+ fields.append(_radio_field("customer_field_grade", "意向等级", code, mapping.GRADE, "picker"))
224
+
225
+ # 重点客户
226
+ if "is_important" in data:
227
+ important_name = data["is_important"]
228
+ code = mapping.IMPORTANT.get(important_name, mapping.IMPORTANT["否"])
229
+ fields.append(_radio_field("customer_field_is_important", "重点客户", code, mapping.IMPORTANT))
230
+
231
+ # 意向描述
232
+ if "intent" in data and data["intent"]:
233
+ fields.append(_text_field("customer_field_intent", "意向描述",
234
+ data["intent"], max_length=1000, display_type="long"))
235
+
236
+ # 客户创建方式(必填)
237
+ fields.append(_create_type_field())
238
+
239
+ return fields
240
+
241
+
242
+ # ============================================================
243
+ # DfcClient 类(统一读写客户端)
244
+ # ============================================================
245
+ class DfcClient:
246
+ """大风车 CRM 客户读写客户端(Token 认证)。"""
247
+
248
+ def __init__(self, token: str, shop_code: str, shop_name: str = "",
249
+ owner_id: str = "", owner_name: str = ""):
250
+ self.token = token
251
+ self.shop_code = shop_code
252
+ self.shop_name = shop_name
253
+ self.owner_id = owner_id
254
+ self.owner_name = owner_name
255
+
256
+ # ---- 读操作 ----
257
+
258
+ def phone_exists(self, phone: str) -> bool:
259
+ """按手机号查大风车是否已有该客户。"""
260
+ payload = {"objCode": "customer", "pageNo": 1, "pageSize": 50, "keywords": phone}
261
+ try:
262
+ result = _http_post(QUERY_URL, payload, self.token)
263
+ data = result.get("data", {})
264
+ records = (data.get("common") or {}).get("records", [])
265
+ for rec in records:
266
+ for f in rec.get("fields", []):
267
+ if f.get("code") == "customer_field_phone" and f.get("value") == phone:
268
+ return True
269
+ return False
270
+ except DfcApiError:
271
+ raise
272
+
273
+ def phone_exists_result(self, phone: str) -> Dict:
274
+ """按手机号查重,返回结构化结果。"""
275
+ try:
276
+ exists = self.phone_exists(phone)
277
+ return {"ok": True, "exists": exists, "error": None}
278
+ except DfcApiError as e:
279
+ return {"ok": False, "exists": None, "error": e.to_dict()}
280
+
281
+ def query_customer_by_phone(self, phone: str) -> Optional[dict]:
282
+ """按手机号查询客户,返回第一个精确匹配的 record,或 None。"""
283
+ payload = {"objCode": "customer", "pageNo": 1, "pageSize": 20, "keywords": phone}
284
+ try:
285
+ result = _http_post(QUERY_URL, payload, self.token)
286
+ data = result.get("data", {})
287
+ records = (data.get("common") or {}).get("records", [])
288
+ for rec in records:
289
+ for field in rec.get("fields", []):
290
+ if field.get("code") == "customer_field_phone" and field.get("value") == phone:
291
+ return rec
292
+ return None
293
+ except DfcApiError:
294
+ return None
295
+
296
+ # ---- 写操作 ----
297
+
298
+ def add_customer(self, lead: Dict, follower_mapping: Dict = None) -> Dict:
299
+ """
300
+ 新增客户(从巨懂车同步的 lead 数据)。
301
+
302
+ Args:
303
+ lead: 客户数据,包含 phone, name, source, grade, status, intent_model, follower 等
304
+ follower_mapping: 跟进人到销售的映射表 {"薛": {"recordId": "xxx", "recordDisplay": "薛"}}
305
+
306
+ Returns:
307
+ {"ok": True, "customer_id": "..."} 或 {"ok": False, "error": {...}}
308
+ """
309
+ # 映射字段
310
+ source_name = "懂车帝" # 固定填懂车帝,不做映射
311
+ grade_name = mapping.map_grade(lead.get("grade", ""), lead.get("status", ""))
312
+ gender_name = mapping.map_gender(lead.get("gender", ""))
313
+
314
+ # 构建意向描述
315
+ intent = ""
316
+ intent_model = (lead.get("intent_model") or "").strip()
317
+ if intent_model:
318
+ intent = f"巨懂车留资:{intent_model}"
319
+
320
+ # 构建简化数据
321
+ data = {
322
+ "phone": lead.get("phone", ""),
323
+ "name": lead.get("name", ""),
324
+ "source": source_name,
325
+ "grade": grade_name,
326
+ "gender": gender_name,
327
+ "is_important": lead.get("is_important", "否"),
328
+ "shop": {"recordId": self.shop_code, "recordDisplay": self.shop_name},
329
+ }
330
+
331
+ # 处理销售/跟进人映射
332
+ follower = (lead.get("follower") or "").strip()
333
+ if follower and follower_mapping and follower in follower_mapping:
334
+ owner_info = follower_mapping[follower]
335
+ data["owner"] = {"recordId": owner_info["recordId"], "recordDisplay": owner_info.get("recordDisplay", follower)}
336
+ elif self.owner_id:
337
+ data["owner"] = {"recordId": self.owner_id, "recordDisplay": self.owner_name}
338
+
339
+ if intent:
340
+ data["intent"] = intent
341
+
342
+ if lead.get("wechat"):
343
+ data["wechat"] = lead["wechat"]
344
+
345
+ # 构建 fields 并发送请求
346
+ fields = build_fields(data)
347
+ payload = {
348
+ "objCode": "customer",
349
+ "businessTypeCode": "customer_default_type",
350
+ "fields": fields,
351
+ }
352
+
353
+ try:
354
+ result = _http_post(SAVE_CUSTOMER_URL, payload, self.token)
355
+ # _http_post 已经在 code != "200" 时抛出异常,成功返回即表示请求成功
356
+ resp_data = result.get("data", {})
357
+ customer_id = resp_data.get("customerId") or resp_data.get("id") or resp_data.get("recordId", "")
358
+ return {"ok": True, "customer_id": customer_id}
359
+ except DfcApiError as e:
360
+ return {"ok": False, "error": e.to_dict()}
361
+
362
+
363
+ # ============================================================
364
+ # 兼容函数
365
+ # ============================================================
366
+ def check_dfc_login(user_data_dir: str) -> Dict:
367
+ """检查大风车登录态 — Token 模式下始终返回 True(只要 APP_KEY 有效)。"""
368
+ try:
369
+ import auth
370
+ token = auth.get_token()
371
+ if token:
372
+ return {"logged_in": True, "source": "app_key_token"}
373
+ except Exception as e:
374
+ return {"logged_in": False, "reason": str(e)}
375
+ return {"logged_in": False, "reason": "APP_KEY 无效"}
376
+
377
+
378
+ if __name__ == "__main__":
379
+ # 测试
380
+ import auth
381
+ token = auth.get_token()
382
+ account = auth.get_account_info(token)
383
+
384
+ client = DfcClient(
385
+ token=token,
386
+ shop_code=account["shopCode"],
387
+ shop_name=account["shopName"],
388
+ )
389
+
390
+ # 测试查重
391
+ result = client.phone_exists_result("13800138099")
392
+ print(f"查重结果: {json.dumps(result, ensure_ascii=False, indent=2)}")