Harden redemption flow and improve operational safety
This commit is contained in:
@@ -43,16 +43,16 @@ class Office365Service:
|
||||
return self._graph_client
|
||||
|
||||
def create_user(self, username: str, password: str | None = None, display_name: str | None = None, retry: bool = True) -> dict[str, Any]:
|
||||
upn, mail_nickname = self._build_user_identifiers(username)
|
||||
client = self._ensure_client()
|
||||
upn = f"{username}@{self.settings.default_domain}"
|
||||
|
||||
password = password or self.settings.default_password
|
||||
display_name = display_name or username
|
||||
display_name = display_name or mail_nickname
|
||||
|
||||
create_payload = {
|
||||
"accountEnabled": True,
|
||||
"displayName": display_name,
|
||||
"mailNickname": username,
|
||||
"mailNickname": mail_nickname,
|
||||
"userPrincipalName": upn,
|
||||
"passwordProfile": {
|
||||
"password": password,
|
||||
@@ -71,8 +71,17 @@ class Office365Service:
|
||||
raise self._translate_graph_error(exc, f"创建用户 {upn} 失败")
|
||||
|
||||
license_result = None
|
||||
license_message = None
|
||||
if self.settings.default_license_sku:
|
||||
license_result = self._assign_license(user["id"])
|
||||
license_result, license_message, license_status = self._assign_license(user["id"])
|
||||
if license_message and self.settings.license_assignment_required:
|
||||
self._rollback_user_for_license_failure(
|
||||
client=client,
|
||||
user_id=user["id"],
|
||||
user_principal_name=upn,
|
||||
license_message=license_message,
|
||||
license_status=license_status,
|
||||
)
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
@@ -80,37 +89,98 @@ class Office365Service:
|
||||
"temporaryPassword": password,
|
||||
"licenseAssigned": bool(license_result),
|
||||
"licenseResult": license_result,
|
||||
"licenseMessage": license_message,
|
||||
}
|
||||
|
||||
def _assign_license(self, user_id: str) -> dict[str, Any]:
|
||||
def _build_user_identifiers(self, username: str) -> tuple[str, str]:
|
||||
normalized = (username or "").strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("请输入用户名。")
|
||||
|
||||
if "@" in normalized:
|
||||
local_part, _, domain = normalized.partition("@")
|
||||
if not local_part or not domain:
|
||||
raise ValueError("请输入有效的完整邮箱地址。")
|
||||
return normalized, local_part
|
||||
|
||||
if not self.settings.default_domain:
|
||||
raise ServiceConfigurationError("DEFAULT_DOMAIN 未配置,请输入完整邮箱地址后重试。")
|
||||
|
||||
return f"{normalized}@{self.settings.default_domain}", normalized
|
||||
|
||||
def _assign_license(self, user_id: str) -> tuple[dict[str, Any] | None, str | None, int]:
|
||||
client = self._ensure_client()
|
||||
sku_part_number = self.settings.default_license_sku
|
||||
|
||||
try:
|
||||
skus = client.list_subscribed_skus()
|
||||
except GraphAPIError as exc:
|
||||
logger.warning("获取许可证列表失败: %s", exc)
|
||||
return None
|
||||
message = f"获取许可证列表失败: {exc.message or exc}"
|
||||
logger.warning(message)
|
||||
return None, message, exc.status_code or 502
|
||||
|
||||
matched = next(
|
||||
(sku for sku in skus if (sku.get("skuPartNumber") or "").upper() == sku_part_number.upper()),
|
||||
None,
|
||||
)
|
||||
if not matched:
|
||||
logger.warning("未找到许可证 SKU: %s", sku_part_number)
|
||||
return None
|
||||
message = f"未找到许可证 SKU: {sku_part_number}"
|
||||
logger.warning(message)
|
||||
return None, message, 409
|
||||
if int(matched.get("consumedUnits", 0) or 0) >= int(matched.get("prepaidUnits", {}).get("enabled", 0) or 0):
|
||||
logger.warning("许可证 %s 已无可用席位", sku_part_number)
|
||||
return None
|
||||
message = f"许可证 {sku_part_number} 已无可用席位"
|
||||
logger.warning(message)
|
||||
return None, message, 409
|
||||
|
||||
try:
|
||||
return client.assign_license(
|
||||
user_id,
|
||||
add_licenses=[{"skuId": matched["skuId"], "disabledPlans": []}],
|
||||
return (
|
||||
client.assign_license(
|
||||
user_id,
|
||||
add_licenses=[{"skuId": matched["skuId"], "disabledPlans": []}],
|
||||
),
|
||||
None,
|
||||
200,
|
||||
)
|
||||
except GraphAPIError as exc:
|
||||
logger.warning("分配许可证失败: %s", exc)
|
||||
return None
|
||||
message = f"分配许可证失败: {exc.message or exc}"
|
||||
logger.warning(message)
|
||||
return None, message, exc.status_code or 502
|
||||
|
||||
def _rollback_user_for_license_failure(
|
||||
self,
|
||||
client: GraphClient,
|
||||
user_id: str,
|
||||
user_principal_name: str,
|
||||
license_message: str,
|
||||
license_status: int,
|
||||
) -> None:
|
||||
try:
|
||||
client.delete_user(user_id)
|
||||
except GraphAPIError as exc:
|
||||
delete_message = exc.message or str(exc)
|
||||
raise ServiceOperationError(
|
||||
message=(
|
||||
f"账号 {user_principal_name} 已创建,但许可证分配失败且回滚删除失败。"
|
||||
f"{license_message};删除失败: {delete_message}"
|
||||
),
|
||||
status_code=502,
|
||||
details={
|
||||
"userPrincipalName": user_principal_name,
|
||||
"licenseError": license_message,
|
||||
"rollbackDeleteError": delete_message,
|
||||
"rolledBack": False,
|
||||
},
|
||||
) from exc
|
||||
|
||||
raise ServiceOperationError(
|
||||
message=f"许可证分配失败,已回滚删除账号 {user_principal_name}。{license_message}",
|
||||
status_code=license_status or 409,
|
||||
details={
|
||||
"userPrincipalName": user_principal_name,
|
||||
"licenseError": license_message,
|
||||
"rolledBack": True,
|
||||
},
|
||||
)
|
||||
|
||||
def _translate_graph_error(self, exc: GraphAPIError, fallback_message: str) -> ServiceOperationError:
|
||||
message = fallback_message
|
||||
@@ -120,4 +190,4 @@ class Office365Service:
|
||||
lowered = message.lower()
|
||||
if "already exists" in lowered or "another object with the same value" in lowered:
|
||||
status_code = 409
|
||||
return ServiceOperationError(message=message, status_code=status_code, details=exc.response)
|
||||
return ServiceOperationError(message=message, status_code=status_code, details=exc.response)
|
||||
|
||||
Reference in New Issue
Block a user