yifaban-cli 0.1.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.
yifaban/cli.py ADDED
@@ -0,0 +1,520 @@
1
+ # -*- coding=utf8 -*-
2
+
3
+ """
4
+ yifaban CLI - 基于易发版账号登录,从本地直接把渠道包发布到各大应用市场
5
+
6
+ 流程:
7
+ 1. yifaban login # 默认 Q助理扫码登录;也可 -u/-p 账号密码登录
8
+ 2. yifaban apps # 查看账号下的应用
9
+ 3. yifaban channels <应用> # 查看各市场渠道配置情况
10
+ 4. 发布(两种方式):
11
+ # 单个 APK 发多个渠道
12
+ yifaban publish ./app.apk --app <应用ID或包名> --version-code 20300 \
13
+ --channels xiaomi,oppo,vivo --update-desc "更新说明"
14
+ # 本地渠道包一对一直传
15
+ yifaban publish --app <应用ID或包名> --version-code 20300 \
16
+ --channel-file huawei=./app-huawei.apk \
17
+ --channel-file xiaomi=./app-xiaomi.apk
18
+ 5. yifaban status <应用> # 查询各渠道审核状态
19
+
20
+ 说明:渠道凭据(client_id/secret 等)全部来自易发版后台配置,
21
+ CLI 只在本机直接上传文件到各市场官方接口,文件不经过服务器。
22
+ 登录会话(token)存于 ~/.yifaban/auth.json(权限 600)。
23
+ """
24
+
25
+ import getpass
26
+ import time
27
+ import webbrowser
28
+ from pathlib import Path
29
+
30
+ import typer
31
+
32
+ from yifaban import api
33
+ from yifaban import config as cfg
34
+ from yifaban.publishers.base import AppInfo
35
+ from yifaban.publishers.honor import HonorPublisher
36
+ from yifaban.publishers.huawei import HuaweiPublisher
37
+ from yifaban.publishers.oppo import OppoPublisher
38
+ from yifaban.publishers.tencent import TencentPublisher
39
+ from yifaban.publishers.vivo import VivoPublisher
40
+ from yifaban.publishers.xiaomi import XiaomiPublisher
41
+
42
+ app = typer.Typer(
43
+ name="yifaban",
44
+ help="基于易发版账号登录,从本地电脑直接把渠道包发布到各大应用市场",
45
+ no_args_is_help=True,
46
+ add_completion=False,
47
+ )
48
+
49
+ CHANNEL_PUBLISHERS = {
50
+ "huawei": HuaweiPublisher,
51
+ "honor": HonorPublisher,
52
+ "xiaomi": XiaomiPublisher,
53
+ "oppo": OppoPublisher,
54
+ "vivo": VivoPublisher,
55
+ "tencent": TencentPublisher,
56
+ }
57
+
58
+ AUDIT_STATUS_LABEL = {
59
+ 0: "审核中",
60
+ 1: "审核通过",
61
+ 2: "审核不通过",
62
+ 3: "其他",
63
+ }
64
+
65
+
66
+ def _split_channels(channels: str):
67
+ if not channels:
68
+ return []
69
+ return [c.strip().lower() for c in channels.split(",") if c.strip()]
70
+
71
+
72
+ def _make_app_info(package_name, app_name, version_code, channel_config) -> AppInfo:
73
+ return AppInfo(
74
+ app_name=app_name,
75
+ package_name=package_name,
76
+ version_code=version_code,
77
+ app_id=str(channel_config.get("app_id", "") or ""),
78
+ )
79
+
80
+
81
+ def _publisher_for(channel: str):
82
+ return CHANNEL_PUBLISHERS.get(channel)
83
+
84
+
85
+ def _require_app(app_ref: str):
86
+ """解析应用:支持 app_id 或 package_name"""
87
+ try:
88
+ apps_list = api.get_apps()
89
+ except api.ApiError as e:
90
+ typer.echo(f"获取应用列表失败: {e}", err=True)
91
+ raise typer.Exit(code=1)
92
+ if not apps_list:
93
+ typer.echo("账号下没有应用,请先在易发版后台创建应用", err=True)
94
+ raise typer.Exit(code=1)
95
+ for a in apps_list:
96
+ if str(a.get("id")) == str(app_ref) or a.get("packageName") == app_ref:
97
+ return a
98
+ typer.echo(f"未找到应用: {app_ref},可用以下应用:", err=True)
99
+ for a in apps_list:
100
+ typer.echo(f" {a.get('id')} {a.get('packageName')} {a.get('appName')}", err=True)
101
+ raise typer.Exit(code=1)
102
+
103
+
104
+ def _load_channel_configs(app_id: str):
105
+ """从服务器拉取该应用所有渠道配置,并转换为发布器所需格式"""
106
+ configs = api.get_channel_configs(app_id)
107
+ result = {}
108
+ for ch, row in (configs or {}).items():
109
+ result[ch] = api.channel_publisher_config(row, ch)
110
+ return result
111
+
112
+
113
+ # ============ 登录与会话 ============
114
+
115
+ QR_POLL_INTERVAL = 2 # 扫码轮询间隔(秒)
116
+ QR_WAIT_TIMEOUT = 5 * 60 # 扫码等待上限(秒)
117
+
118
+
119
+ def _login_with_qzhuli(target: str):
120
+ """Q助理扫码登录:取授权地址 → 浏览器打开扫码页 → 轮询 → 兑换;未绑定则交互绑定"""
121
+ try:
122
+ qz = api.qzhuli_auth_url(target)
123
+ except api.ApiError as e:
124
+ typer.echo(f"获取 Q助理扫码授权地址失败: {e}", err=True)
125
+ raise typer.Exit(code=1)
126
+ url = qz["url"]
127
+ typer.echo("已打开默认浏览器,请在授权页用 Q助理 App 扫码并确认(授权页约 10 分钟有效):")
128
+ try:
129
+ if not webbrowser.open(url):
130
+ raise RuntimeError("webbrowser.open returned False")
131
+ except Exception as e:
132
+ typer.echo(f"自动打开浏览器失败({e}),请手动打开以下地址扫码:", err=True)
133
+ typer.echo(url, err=True)
134
+ typer.echo("等待扫码并确认授权…(可 Ctrl+C 取消)")
135
+
136
+ deadline = time.time() + QR_WAIT_TIMEOUT
137
+ ticket = None
138
+ while time.time() < deadline:
139
+ time.sleep(QR_POLL_INTERVAL)
140
+ try:
141
+ st = api.qzhuli_poll(target, qz["request_id"])
142
+ except api.ApiError as e:
143
+ typer.echo(f"轮询失败: {e}", err=True)
144
+ raise typer.Exit(code=1)
145
+ status = st.get("status")
146
+ if status == "ok":
147
+ ticket = st.get("ticket")
148
+ break
149
+ if status == "expired":
150
+ typer.echo("二维码已过期,请重新运行 yifaban login", err=True)
151
+ raise typer.Exit(code=1)
152
+ else:
153
+ typer.echo("等待扫码超时,请重新运行 yifaban login", err=True)
154
+ raise typer.Exit(code=1)
155
+
156
+ try:
157
+ result = api.qzhuli_exchange(target, ticket)
158
+ except api.ApiError as e:
159
+ typer.echo(f"登录失败: {e}", err=True)
160
+ raise typer.Exit(code=1)
161
+ if result.get("token") and result.get("uid"):
162
+ api.save_login_session(target, result)
163
+ typer.echo(f"登录成功: {result.get('username')} @ {target}")
164
+ return
165
+ if result.get("needBind"):
166
+ nickname = result.get("nickname") or "该 Q助理 账号"
167
+ typer.echo(f"{nickname} 尚未绑定易发版账号,请输入易发版账号完成绑定:")
168
+ bind_username = typer.prompt("易发版账号")
169
+ bind_password = getpass.getpass("易发版密码: ")
170
+ try:
171
+ result2 = api.qzhuli_bind(target, result["bindToken"], bind_username, bind_password)
172
+ except api.ApiError as e:
173
+ typer.echo(f"绑定失败: {e}", err=True)
174
+ raise typer.Exit(code=1)
175
+ if result2.get("token") and result2.get("uid"):
176
+ api.save_login_session(target, result2, bind_username)
177
+ typer.echo(f"绑定并登录成功: {result2.get('username')} @ {target}")
178
+ return
179
+ typer.echo("登录失败,请重试", err=True)
180
+ raise typer.Exit(code=1)
181
+
182
+
183
+ @app.command("login")
184
+ def login_cmd(
185
+ username: str = typer.Option(
186
+ None, "--username", "-u",
187
+ help="易发版账号(需与 --password 同时提供,走账号密码登录)",
188
+ ),
189
+ password: str = typer.Option(
190
+ None, "--password", "-p",
191
+ help="易发版密码(需与 --username 同时提供,走账号密码登录)",
192
+ ),
193
+ server: str = typer.Option(None, "--server", help=f"易发版服务地址(默认 {cfg.DEFAULT_SERVER})"),
194
+ ):
195
+ """
196
+ 登录易发版。
197
+
198
+ 默认使用 Q助理扫码登录(自动打开默认浏览器显示授权扫码页);
199
+ 提供 --username/--password 时走账号密码登录(适合非交互/脚本场景)。
200
+ """
201
+ target = server or cfg.get_server()
202
+ if bool(username) != bool(password):
203
+ typer.echo("账号密码登录需同时提供 --username 和 --password(或都不提供,走 Q助理扫码)", err=True)
204
+ raise typer.Exit(code=2)
205
+ if username and password:
206
+ try:
207
+ result = api.login(username, password, target)
208
+ except api.ApiError as e:
209
+ typer.echo(f"登录失败: {e}", err=True)
210
+ raise typer.Exit(code=1)
211
+ typer.echo(f"登录成功: {result.get('username')} @ {target}")
212
+ return
213
+ _login_with_qzhuli(target)
214
+
215
+
216
+ @app.command("logout")
217
+ def logout_cmd():
218
+ """退出登录,清除本地会话"""
219
+ auth = cfg.load_auth()
220
+ if not auth.get("token"):
221
+ typer.echo("当前未登录")
222
+ return
223
+ cfg.clear_auth()
224
+ typer.echo("已退出登录")
225
+
226
+
227
+ @app.command("whoami")
228
+ def whoami_cmd():
229
+ """查看当前登录状态"""
230
+ auth = cfg.load_auth()
231
+ if not auth.get("token"):
232
+ typer.echo("未登录,请先运行 yifaban login")
233
+ raise typer.Exit(code=1)
234
+ typer.echo(f"账号: {auth.get('username')}")
235
+ typer.echo(f"uid: {auth.get('uid')}")
236
+ typer.echo(f"服务器: {auth.get('server')}")
237
+
238
+
239
+ # ============ 应用与渠道 ============
240
+
241
+ @app.command("apps")
242
+ def apps_cmd():
243
+ """列出账号下的应用"""
244
+ try:
245
+ apps_list = api.get_apps()
246
+ except api.ApiError as e:
247
+ typer.echo(f"获取应用列表失败: {e}", err=True)
248
+ raise typer.Exit(code=1)
249
+ if not apps_list:
250
+ typer.echo("账号下没有应用")
251
+ return
252
+ typer.echo(f"{'ID':<10}{'包名':<35}{'应用名称':<20}付费")
253
+ for a in apps_list:
254
+ typer.echo(
255
+ f"{str(a.get('id')):<10}"
256
+ f"{str(a.get('packageName')):<35}"
257
+ f"{str(a.get('appName')):<20}"
258
+ f"{'是' if a.get('isPaid') else '否'}"
259
+ )
260
+
261
+
262
+ @app.command("channels")
263
+ def channels_cmd(
264
+ app_ref: str = typer.Argument(..., help="应用ID或包名"),
265
+ ):
266
+ """查看某应用在各市场已配置的渠道(凭据脱敏)"""
267
+ a = _require_app(app_ref)
268
+ try:
269
+ configs = api.get_channel_configs(a.get("id"))
270
+ except api.ApiError as e:
271
+ typer.echo(f"获取渠道配置失败: {e}", err=True)
272
+ raise typer.Exit(code=1)
273
+ if not configs:
274
+ typer.echo(f"{a.get('appName')} 还没有配置任何渠道,请在易发版后台配置")
275
+ return
276
+ for ch, row in sorted(configs.items()):
277
+ cd = (row or {}).get("configData") or {}
278
+ fields = ", ".join(
279
+ f"{k}={cfg.mask_secret(str(v))}" for k, v in cd.items() if v is not None and str(v)
280
+ )
281
+ typer.echo(f"[{ch}] {fields or '(配置字段为空)'}")
282
+
283
+
284
+ # ============ 验证与发布 ============
285
+
286
+ @app.command("test-config")
287
+ def test_config_cmd(
288
+ app_ref: str = typer.Argument(..., help="应用ID或包名"),
289
+ channels: str = typer.Option("", "--channels", help="逗号分隔;缺省测试所有已配置渠道"),
290
+ ):
291
+ """验证各渠道凭据:本地直连各市场查询应用信息"""
292
+ a = _require_app(app_ref)
293
+ channel_cfgs = _load_channel_configs(a.get("id"))
294
+ target = _split_channels(channels) or list(channel_cfgs.keys())
295
+ if not target:
296
+ typer.echo(f"{a.get('appName')} 未配置任何渠道,先在易发版后台配置", err=True)
297
+ raise typer.Exit(code=1)
298
+
299
+ failed = False
300
+ for ch in target:
301
+ ch_cfg = channel_cfgs.get(ch)
302
+ Publisher = _publisher_for(ch)
303
+ if not ch_cfg:
304
+ typer.echo(f"[{ch}] 该渠道未配置(请在易发版后台配置)", err=True)
305
+ failed = True
306
+ continue
307
+ if not Publisher:
308
+ typer.echo(f"[{ch}] 不支持的渠道", err=True)
309
+ failed = True
310
+ continue
311
+ app_info = _make_app_info(a.get("packageName"), a.get("appName"), "", ch_cfg)
312
+ pub = Publisher(app_info=app_info, config=ch_cfg)
313
+ try:
314
+ ok, info = pub.fetch_app_info_from_market()
315
+ if ok:
316
+ typer.echo(f"[{ch}] 连接正常:")
317
+ for k, v in info.__dict__.items():
318
+ if v:
319
+ typer.echo(f" {k}: {v}")
320
+ else:
321
+ typer.echo(f"[{ch}] 连接失败: {info}", err=True)
322
+ failed = True
323
+ except Exception as e:
324
+ typer.echo(f"[{ch}] 查询异常: {e}", err=True)
325
+ failed = True
326
+ if failed:
327
+ raise typer.Exit(code=1)
328
+
329
+
330
+ @app.command("publish")
331
+ def publish_cmd(
332
+ apk_path: Path = typer.Argument(
333
+ None,
334
+ help="APK 路径;使用 --channel-file 指定渠道包时可省略",
335
+ ),
336
+ app_ref: str = typer.Option(..., "--app", help="应用ID或包名(必填)"),
337
+ version_code: str = typer.Option(..., "--version-code", help="版本号,必须高于各市场当前版本"),
338
+ version_name: str = typer.Option("", "--version-name", help="版本名称(仅展示用)"),
339
+ update_desc: str = typer.Option("", "--update-desc", help="本次更新说明"),
340
+ channels: str = typer.Option("", "--channels", help="逗号分隔的渠道;缺省发布所有已配置渠道"),
341
+ channel_file: list[str] = typer.Option(
342
+ None,
343
+ "--channel-file",
344
+ help="渠道包映射 channel=路径,可多次指定;指定后该渠道用对应文件直传",
345
+ ),
346
+ no_review: bool = typer.Option(False, "--no-review", help="华为渠道上传后不自动提交审核"),
347
+ ):
348
+ """
349
+ 直接把本地 APK 发布到指定应用市场。
350
+
351
+ 凭据来自易发版后台配置;文件在本机直接上传到各市场官方接口,不经过服务器。
352
+ """
353
+ a = _require_app(app_ref)
354
+ channel_cfgs = _load_channel_configs(a.get("id"))
355
+
356
+ # 解析 --channel-file
357
+ channel_file_map = {}
358
+ if channel_file:
359
+ for item in channel_file:
360
+ if "=" not in item:
361
+ typer.echo(f"--channel-file 需为 channel=路径 格式: {item}", err=True)
362
+ raise typer.Exit(code=2)
363
+ ch, path = item.split("=", 1)
364
+ ch = ch.strip().lower()
365
+ p = Path(path)
366
+ if not p.exists():
367
+ typer.echo(f"渠道 {ch} 的文件不存在: {path}", err=True)
368
+ raise typer.Exit(code=2)
369
+ channel_file_map[ch] = str(p)
370
+
371
+ # 合并渠道列表
372
+ target_channels = _split_channels(channels)
373
+ if channel_file_map:
374
+ target_channels = list(dict.fromkeys(list(channel_file_map.keys()) + target_channels))
375
+ if not target_channels:
376
+ target_channels = [c for c, cf in channel_cfgs.items() if cf]
377
+ if not target_channels:
378
+ typer.echo(f"{a.get('appName')} 未配置任何渠道,请先指定 --channels 或 --channel-file", err=True)
379
+ raise typer.Exit(code=1)
380
+
381
+ # 文件映射
382
+ resolved = {}
383
+ for ch in target_channels:
384
+ if ch in channel_file_map:
385
+ resolved[ch] = channel_file_map[ch]
386
+ else:
387
+ if apk_path is None:
388
+ typer.echo(f"渠道 {ch} 未指定文件,请提供 apk_path 或 --channel-file", err=True)
389
+ raise typer.Exit(code=2)
390
+ if not apk_path.exists():
391
+ typer.echo(f"APK 文件不存在: {apk_path}", err=True)
392
+ raise typer.Exit(code=2)
393
+ resolved[ch] = str(apk_path)
394
+
395
+ failed = False
396
+ for ch in target_channels:
397
+ ch_cfg = channel_cfgs.get(ch)
398
+ Publisher = _publisher_for(ch)
399
+ if not ch_cfg:
400
+ typer.echo(f"[{ch}] 该渠道未配置(请在易发版后台配置后重试)", err=True)
401
+ failed = True
402
+ continue
403
+ if not Publisher:
404
+ typer.echo(f"[{ch}] 不支持的渠道", err=True)
405
+ failed = True
406
+ continue
407
+
408
+ typer.echo(f"========== 发布到 {ch}: {resolved[ch]} ==========")
409
+ app_info = _make_app_info(a.get("packageName"), a.get("appName"), version_code, ch_cfg)
410
+ pub = Publisher(app_info=app_info, config=ch_cfg)
411
+ try:
412
+ res = pub.submit(file_path=resolved[ch], update_desc=update_desc)
413
+ if len(res) == 2:
414
+ ok, msg = res
415
+ market_id = None
416
+ else:
417
+ ok, msg, market_id = res
418
+
419
+ if ok:
420
+ extra = f"(市场发布ID: {market_id})" if market_id else ""
421
+ typer.echo(f"[{ch}] 发布成功: {msg}{extra}")
422
+ if ch == "huawei" and not no_review:
423
+ typer.echo(f"[{ch}] 华为要求上传后等待 2 分钟再提交审核...")
424
+ time.sleep(120)
425
+ try:
426
+ pub.check_result()
427
+ typer.echo(f"[{ch}] 已提交审核")
428
+ except Exception as e:
429
+ typer.echo(f"[{ch}] 提交审核失败: {e}(可用 yifaban submit-review 重试)", err=True)
430
+ failed = True
431
+ else:
432
+ typer.echo(f"[{ch}] 发布失败: {msg}", err=True)
433
+ failed = True
434
+ except Exception as e:
435
+ typer.echo(f"[{ch}] 发布异常: {e}", err=True)
436
+ failed = True
437
+
438
+ if failed:
439
+ raise typer.Exit(code=1)
440
+
441
+
442
+ @app.command("submit-review")
443
+ def submit_review_cmd(
444
+ app_ref: str = typer.Argument(..., help="应用ID或包名"),
445
+ channel: str = typer.Argument(..., help="渠道(通常为 huawei)"),
446
+ ):
447
+ """手动补交审核(华为 --no-review 场景,或自动提交失败后重试)"""
448
+ ch = channel.lower()
449
+ a = _require_app(app_ref)
450
+ channel_cfgs = _load_channel_configs(a.get("id"))
451
+ ch_cfg = channel_cfgs.get(ch)
452
+ Publisher = _publisher_for(ch)
453
+ if not ch_cfg:
454
+ typer.echo(f"[{ch}] 该渠道未配置", err=True)
455
+ raise typer.Exit(code=1)
456
+ if not Publisher:
457
+ typer.echo(f"[{ch}] 不支持的渠道", err=True)
458
+ raise typer.Exit(code=2)
459
+ app_info = _make_app_info(a.get("packageName"), a.get("appName"), "", ch_cfg)
460
+ pub = Publisher(app_info=app_info, config=ch_cfg)
461
+ try:
462
+ result = pub.check_result()
463
+ typer.echo(f"[{ch}] 提交审核成功: {result}")
464
+ except Exception as e:
465
+ typer.echo(f"[{ch}] 提交审核失败: {e}", err=True)
466
+ raise typer.Exit(code=1)
467
+
468
+
469
+ @app.command("status")
470
+ def status_cmd(
471
+ app_ref: str = typer.Argument(..., help="应用ID或包名"),
472
+ channels: str = typer.Option("", "--channels", help="逗号分隔;缺省查询所有已配置渠道"),
473
+ release_id: str = typer.Option("", "--release-id", help="荣耀渠道查询审核状态时需要(发布时返回的市场发布ID)"),
474
+ ):
475
+ """查询各渠道审核状态"""
476
+ a = _require_app(app_ref)
477
+ channel_cfgs = _load_channel_configs(a.get("id"))
478
+ target = _split_channels(channels) or list(channel_cfgs.keys())
479
+ if not target:
480
+ typer.echo(f"{a.get('appName')} 未配置任何渠道", err=True)
481
+ raise typer.Exit(code=1)
482
+
483
+ failed = False
484
+ for ch in target:
485
+ ch_cfg = channel_cfgs.get(ch)
486
+ Publisher = _publisher_for(ch)
487
+ if not ch_cfg:
488
+ typer.echo(f"[{ch}] 该渠道未配置", err=True)
489
+ failed = True
490
+ continue
491
+ if not Publisher:
492
+ typer.echo(f"[{ch}] 不支持的渠道", err=True)
493
+ failed = True
494
+ continue
495
+ app_info = _make_app_info(a.get("packageName"), a.get("appName"), "", ch_cfg)
496
+ pub = Publisher(app_info=app_info, config=ch_cfg)
497
+ try:
498
+ if ch == "honor":
499
+ if not release_id:
500
+ typer.echo(f"[{ch}] 荣耀需 --release-id(发布时输出的市场发布ID)", err=True)
501
+ failed = True
502
+ continue
503
+ code, msg = pub.check_audit_status(data={"releaseId": release_id})
504
+ else:
505
+ code, msg = pub.check_audit_status()
506
+ label = AUDIT_STATUS_LABEL.get(code, f"未知({code})")
507
+ typer.echo(f"[{ch}] {label}: {msg}")
508
+ except Exception as e:
509
+ typer.echo(f"[{ch}] 查询失败: {e}", err=True)
510
+ failed = True
511
+ if failed:
512
+ raise typer.Exit(code=1)
513
+
514
+
515
+ def main():
516
+ app()
517
+
518
+
519
+ if __name__ == "__main__":
520
+ main()
yifaban/config.py ADDED
@@ -0,0 +1,78 @@
1
+ # -*- coding=utf8 -*-
2
+
3
+ """
4
+ 本地会话与配置管理
5
+
6
+ CLI 使用易发版账号登录后,从服务器拉取各应用市场的渠道凭据;
7
+ 本地只保存登录会话(token/uid)与服务器地址,不落盘渠道密钥。
8
+
9
+ 可用环境变量 APKPUB_SERVER 指定服务器地址,
10
+ 用 APKPUB_<CHANNEL>_<FIELD> 覆盖单个渠道字段(调试用)。
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import stat
16
+ from pathlib import Path
17
+
18
+ CONFIG_DIR = Path.home() / ".yifaban"
19
+ AUTH_FILE = CONFIG_DIR / "auth.json"
20
+ ENV_PREFIX = "APKPUB_"
21
+ DEFAULT_SERVER = "https://yifaban.com"
22
+
23
+
24
+ def _chmod_600(path: Path):
25
+ try:
26
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
27
+ except OSError:
28
+ pass
29
+
30
+
31
+ def save_auth(data: dict):
32
+ """保存登录会话(token/uid/server)"""
33
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
34
+ with open(AUTH_FILE, "w", encoding="utf-8") as f:
35
+ json.dump(data, f, ensure_ascii=False, indent=2)
36
+ _chmod_600(AUTH_FILE)
37
+
38
+
39
+ def load_auth() -> dict:
40
+ if not AUTH_FILE.exists():
41
+ return {}
42
+ try:
43
+ with open(AUTH_FILE, "r", encoding="utf-8") as f:
44
+ return json.load(f) or {}
45
+ except Exception:
46
+ return {}
47
+
48
+
49
+ def clear_auth():
50
+ if AUTH_FILE.exists():
51
+ AUTH_FILE.unlink()
52
+
53
+
54
+ def get_server() -> str:
55
+ """服务器地址:--server 优先,其次环境变量,最后默认值"""
56
+ auth = load_auth()
57
+ return auth.get("server") or os.environ.get("APKPUB_SERVER") or DEFAULT_SERVER
58
+
59
+
60
+ def apply_env_overrides(channel: str, cfg: dict) -> dict:
61
+ """环境变量 APKPUB_<CHANNEL>_<FIELD> 覆盖渠道配置字段"""
62
+ result = dict(cfg or {})
63
+ for field in list(result.keys()):
64
+ env_key = f"{ENV_PREFIX}{channel.upper()}_{field.upper()}"
65
+ env_val = os.environ.get(env_key)
66
+ if env_val is not None:
67
+ result[field] = env_val
68
+ return result
69
+
70
+
71
+ def mask_secret(value) -> str:
72
+ """脱敏显示"""
73
+ if not value:
74
+ return ""
75
+ value = str(value)
76
+ if len(value) <= 8:
77
+ return "*" * len(value)
78
+ return value[:4] + "*" * (len(value) - 8) + value[-4:]
yifaban/constants.py ADDED
@@ -0,0 +1,34 @@
1
+ # -*- coding=utf8 -*-
2
+
3
+ """
4
+ 常量定义
5
+ """
6
+
7
+ # 渠道名称常量
8
+ HUAWEI_CHANNEL_NAME = "huawei"
9
+ XIAOMI_CHANNEL_NAME = "xiaomi"
10
+ OPPO_CHANNEL_NAME = "oppo"
11
+ TENCENT_CHANNEL_NAME = "tencent"
12
+ VIVO_CHANNEL_NAME = "vivo"
13
+ HONOR_CHANNEL_NAME = "honor"
14
+
15
+ # 发布任务状态常量
16
+ PUBLISH_TASK_STATUS_PENDING = 0 # 0-未开始
17
+ PUBLISH_TASK_STATUS_COMPLETED = 1 # 1-结束/完成
18
+ PUBLISH_TASK_STATUS_PROCESSING = 2 # 2-处理中
19
+
20
+ # 渠道任务状态常量
21
+ CHANNEL_TASK_STATUS_PENDING = 0 # 0-未开始
22
+ CHANNEL_TASK_STATUS_COMPLETED = 1 # 1-完成
23
+ CHANNEL_TASK_STATUS_PROCESSING = 2 # 2-处理中/发布中
24
+ CHANNEL_TASK_STATUS_REVIEWING = 3 # 3-审核中
25
+ CHANNEL_TASK_STATUS_FAILED = 4 # 4-发布失败
26
+ CHANNEL_TASK_STATUS_AUDIT_FAILED = 5 # 5-审核失败
27
+
28
+ # 应用市场审核的状态
29
+ AUDIT_STATUS_PROCESSING = 0 # 0 审核中
30
+ AUDIT_STATUS_COMPLETED = 1 # 1-审核通过
31
+ AUDIT_STATUS_FAILED = 2 # 2-审核不通过
32
+ AUDIT_STATUS_OTHER = 3 # 3-其他
33
+
34
+
yifaban/errors.py ADDED
@@ -0,0 +1,14 @@
1
+ # -*- coding=utf8 -*-
2
+
3
+ """
4
+ 错误处理模块(原 ErrorJson.py)
5
+ """
6
+
7
+
8
+ def api_error_json(message: str):
9
+ """生成 API 错误 JSON"""
10
+ return {
11
+ "code": 500,
12
+ "message": message
13
+ }
14
+